D47crunch

Standardization and analytical error propagation of Δ47 and Δ48 clumped-isotope measurements

Process and standardize carbonate and/or CO2 clumped-isotope analyses, from low-level data out of a dual-inlet mass spectrometer to final, “absolute” Δ47 and Δ48 values with fully propagated analytical error estimates (Daëron, 2021).

The tutorial section takes you through a series of simple steps to import/process data and print out the results. The how-to section provides instructions applicable to various specific tasks.

1. Tutorial

1.1 Installation

The easy option is to use pip; open a shell terminal and simply type:

python -m pip install D47crunch

For those wishing to experiment with the bleeding-edge development version, this can be done through the following steps:

  1. Download the dev branch source code here and rename it to D47crunch.py.
  2. Do any of the following:
    • copy D47crunch.py to somewhere in your Python path
    • copy D47crunch.py to a working directory (import D47crunch will only work if called within that directory)
    • copy D47crunch.py to any other location (e.g., /foo/bar) and then use the following code snippet in your own code to import D47crunch:
import sys
sys.path.append('/foo/bar')
import D47crunch

Documentation for the development version can be downloaded here (save html file and open it locally).

1.2 Usage

Start by creating a file named rawdata.csv with the following contents:

UID,  Sample,           d45,       d46,        d47,        d48,       d49
A01,  ETH-1,        5.79502,  11.62767,   16.89351,   24.56708,   0.79486
A02,  MYSAMPLE-1,   6.21907,  11.49107,   17.27749,   24.58270,   1.56318
A03,  ETH-2,       -6.05868,  -4.81718,  -11.63506,  -10.32578,   0.61352
A04,  MYSAMPLE-2,  -3.86184,   4.94184,    0.60612,   10.52732,   0.57118
A05,  ETH-3,        5.54365,  12.05228,   17.40555,   25.96919,   0.74608
A06,  ETH-2,       -6.06706,  -4.87710,  -11.69927,  -10.64421,   1.61234
A07,  ETH-1,        5.78821,  11.55910,   16.80191,   24.56423,   1.47963
A08,  MYSAMPLE-2,  -3.87692,   4.86889,    0.52185,   10.40390,   1.07032

Then instantiate a D47data object which will store and process this data:

import D47crunch
mydata = D47crunch.D47data()

For now, this object is empty:

>>> print(mydata)
[]

To load the analyses saved in rawdata.csv into our D47data object and process the data:

mydata.read('rawdata.csv')

# compute δ13C, δ18O of working gas:
mydata.wg()

# compute δ13C, δ18O, raw Δ47 values for each analysis:
mydata.crunch()

# compute absolute Δ47 values for each analysis
# as well as average Δ47 values for each sample:
mydata.standardize()

We can now print a summary of the data processing:

>>> mydata.summary(verbose = True, save_to_file = False)
[summary]        
–––––––––––––––––––––––––––––––  –––––––––
N samples (anchors + unknowns)   5 (3 + 2)
N analyses (anchors + unknowns)  8 (5 + 3)
Repeatability of δ13C_VPDB         4.2 ppm
Repeatability of δ18O_VSMOW       47.5 ppm
Repeatability of Δ47 (anchors)    13.4 ppm
Repeatability of Δ47 (unknowns)    2.5 ppm
Repeatability of Δ47 (all)         9.6 ppm
Model degrees of freedom                 3
Student's 95% t-factor                3.18
Standardization method              pooled
–––––––––––––––––––––––––––––––  –––––––––

This tells us that our data set contains 5 different samples: 3 anchors (ETH-1, ETH-2, ETH-3) and 2 unknowns (MYSAMPLE-1, MYSAMPLE-2). The total number of analyses is 8, with 5 anchor analyses and 3 unknown analyses. We get an estimate of the analytical repeatability (i.e. the overall, pooled standard deviation) for δ13C, δ18O and Δ47, as well as the number of degrees of freedom (here, 3) that these estimated standard deviations are based on, along with the corresponding Student's t-factor (here, 3.18) for 95 % confidence limits. Finally, the summary indicates that we used a “pooled” standardization approach (see [Daëron, 2021]).

To see the actual results:

>>> mydata.table_of_samples(verbose = True, save_to_file = False)
[table_of_samples] 
––––––––––  –  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
Sample      N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
––––––––––  –  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
ETH-1       2       2.01       37.01  0.2052                    0.0131          
ETH-2       2     -10.17       19.88  0.2085                    0.0026          
ETH-3       1       1.73       37.49  0.6132                                    
MYSAMPLE-1  1       2.48       36.90  0.2996  0.0091  ± 0.0291                  
MYSAMPLE-2  2      -8.17       30.05  0.6600  0.0115  ± 0.0366  0.0025          
––––––––––  –  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––

This table lists, for each sample, the number of analytical replicates, average δ13C and δ18O values (for the analyte CO2 , not for the carbonate itself), the average Δ47 value and the SD of Δ47 for all replicates of this sample. For unknown samples, the SE and 95 % confidence limits for mean Δ47 are also listed These 95 % CL take into account the number of degrees of freedom of the regression model, so that in large datasets the 95 % CL will tend to 1.96 times the SE, but in this case the applicable t-factor is much larger.

We can also generate a table of all analyses in the data set (again, note that d18O_VSMOW is the composition of the CO2 analyte):

>>> mydata.table_of_analyses(verbose = True, save_to_file = False)
[table_of_analyses] 
–––  –––––––––  ––––––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––
UID    Session      Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48       d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw      D49raw       D47
–––  –––––––––  ––––––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––
A01  mySession       ETH-1       -3.807        24.921   5.795020  11.627670   16.893510   24.567080  0.794860    2.014086   37.041843  -0.574686   1.149684  -27.690250  0.214454
A02  mySession  MYSAMPLE-1       -3.807        24.921   6.219070  11.491070   17.277490   24.582700  1.563180    2.476827   36.898281  -0.499264   1.435380  -27.122614  0.299589
A03  mySession       ETH-2       -3.807        24.921  -6.058680  -4.817180  -11.635060  -10.325780  0.613520  -10.166796   19.907706  -0.685979  -0.721617   16.716901  0.206693
A04  mySession  MYSAMPLE-2       -3.807        24.921  -3.861840   4.941840    0.606120   10.527320  0.571180   -8.159927   30.087230  -0.248531   0.613099   -4.979413  0.658270
A05  mySession       ETH-3       -3.807        24.921   5.543650  12.052280   17.405550   25.969190  0.746080    1.727029   37.485567  -0.226150   1.678699  -28.280301  0.613200
A06  mySession       ETH-2       -3.807        24.921  -6.067060  -4.877100  -11.699270  -10.644210  1.612340  -10.173599   19.845192  -0.683054  -0.922832   17.861363  0.210328
A07  mySession       ETH-1       -3.807        24.921   5.788210  11.559100   16.801910   24.564230  1.479630    2.009281   36.970298  -0.591129   1.282632  -26.888335  0.195926
A08  mySession  MYSAMPLE-2       -3.807        24.921  -3.876920   4.868890    0.521850   10.403900  1.070320   -8.173486   30.011134  -0.245768   0.636159   -4.324964  0.661803
–––  –––––––––  ––––––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––

2. How-to

2.1 Simulate a virtual data set to play with

It is sometimes convenient to quickly build a virtual data set of analyses, for instance to assess the final analytical precision achievable for a given combination of anchor and unknown analyses (see also Fig. 6 of Daëron, 2021).

This can be achieved with virtual_data(). The example below creates a dataset with four sessions, each of which comprises four analyses of anchor ETH-1, five of ETH-2, six of ETH-3, and two analyses of an unknown sample named FOO with an arbitrarily defined isotopic composition. Analytical repeatabilities for Δ47 and Δ48 are also specified arbitrarily. See the virtual_data() documentation for additional configuration parameters.

from D47crunch import *

args = dict(
    samples = [
        dict(Sample = 'ETH-1', N = 4),
        dict(Sample = 'ETH-2', N = 5),
        dict(Sample = 'ETH-3', N = 6),
        dict(
            Sample = 'FOO',
            N = 2,
            d13C_VPDB = -5.,
            d18O_VPDB = -10.,
            D47 = 0.3,
            D48 = 0.15
            ),
        ],
    rD47 = 0.010,
    rD48 = 0.030,
    )

session1 = virtual_data(session = 'Session_01', **args)
session2 = virtual_data(session = 'Session_02', **args)
session3 = virtual_data(session = 'Session_03', **args)
session4 = virtual_data(session = 'Session_04', **args)

D = D47data(session1 + session2 + session3 + session4)

D.crunch()
D.standardize()

D.table_of_sessions(verbose = True, save_to_file = False)
D.table_of_samples(verbose = True, save_to_file = False)
D.table_of_analyses(verbose = True, save_to_file = False)

2.2 Control data quality

D47crunch offers several tools to visualize processed data. The examples below use the same virtual data set, generated with:

from D47crunch import *
from random import shuffle

# generate virtual data:
args = dict(
    samples = [
        dict(Sample = 'ETH-1', N = 8),
        dict(Sample = 'ETH-2', N = 8),
        dict(Sample = 'ETH-3', N = 8),
        dict(Sample = 'FOO', N = 4,
            d13C_VPDB = -5., d18O_VPDB = -10.,
            D47 = 0.3, D48 = 0.15),
        dict(Sample = 'BAR', N = 4,
            d13C_VPDB = -15., d18O_VPDB = -15.,
            D47 = 0.5, D48 = 0.2),
        ])

sessions = [
    virtual_data(session = f'Session_{k+1:02.0f}', seed = int('1234567890'[:k+1]), **args)
    for k in range(10)]

# shuffle the data:
data = [r for s in sessions for r in s]
shuffle(data)
data = sorted(data, key = lambda r: r['Session'])

# create D47data instance:
data47 = D47data(data)

# process D47data instance:
data47.crunch()
data47.standardize()

2.2.1 Plotting the distribution of analyses through time

data47.plot_distribution_of_analyses(filename = 'time_distribution.pdf')

time_distribution.png

The plot above shows the succession of analyses as if they were all distributed at regular time intervals. See D4xdata.plot_distribution_of_analyses() for how to plot analyses as a function of “true” time (based on the TimeTag for each analysis).

2.2.2 Generating session plots

data47.plot_sessions()

Below is one of the resulting sessions plots. Each cross marker is an analysis. Anchors are in red and unknowns in blue. Short horizontal lines show the nominal Δ47 value for anchors, in red, or the average Δ47 value for unknowns, in blue (overall average for all sessions). Curved grey contours correspond to Δ47 standardization errors in this session.

D47_plot_Session_03.png

2.2.3 Plotting Δ47 or Δ48 residuals

data47.plot_residuals(filename = 'residuals.pdf')

residuals.png

Again, note that this plot only shows the succession of analyses as if they were all distributed at regular time intervals.

2.3 Use a different set of anchors, change anchor nominal values, and/or change oxygen-17 correction parameters

Nominal values for various carbonate standards are defined in four places:

17O correction parameters are defined by:

When creating a new instance of D47data or D48data, the current values of these variables are copied as properties of the new object. Applying custom values for, e.g., R17_VSMOW and Nominal_D47 can thus be done in several ways:

Option 1: by redefining D4xdata.R17_VSMOW and D47data.Nominal_D47 _before_ creating a D47data object:

from D47crunch import D4xdata, D47data

# redefine R17_VSMOW:
D4xdata.R17_VSMOW = 0.00037 # new value

# redefine R17_VPDB for consistency:
D4xdata.R17_VPDB = D4xdata.R17_VSMOW * (D4xdata.R18_VPDB/D4xdata.R18_VSMOW) ** D4xdata.LAMBDA_17

# edit Nominal_D47 to only include ETH-1/2/3:
D47data.Nominal_D4x = {
    a: D47data.Nominal_D4x[a]
    for a in ['ETH-1', 'ETH-2', 'ETH-3']
    }
# redefine ETH-3:
D47data.Nominal_D4x['ETH-3'] = 0.600

# only now create D47data object:
mydata = D47data()

# check the results:
print(mydata.R17_VSMOW, mydata.R17_VPDB)
print(mydata.Nominal_D47)
# NB: mydata.Nominal_D47 is just an alias for mydata.Nominal_D4x

# should print out:
# 0.00037 0.00037599710894149464
# {'ETH-1': 0.2052, 'ETH-2': 0.2085, 'ETH-3': 0.6}

Option 2: by redefining R17_VSMOW and Nominal_D47 _after_ creating a D47data object:

from D47crunch import D47data

# first create D47data object:
mydata = D47data()

# redefine R17_VSMOW:
mydata.R17_VSMOW = 0.00037 # new value

# redefine R17_VPDB for consistency:
mydata.R17_VPDB = mydata.R17_VSMOW * (mydata.R18_VPDB/mydata.R18_VSMOW) ** mydata.LAMBDA_17

# edit Nominal_D47 to only include ETH-1/2/3:
mydata.Nominal_D47 = {
    a: mydata.Nominal_D47[a]
    for a in ['ETH-1', 'ETH-2', 'ETH-3']
    }
# redefine ETH-3:
mydata.Nominal_D47['ETH-3'] = 0.600

# check the results:
print(mydata.R17_VSMOW, mydata.R17_VPDB)
print(mydata.Nominal_D47)

# should print out:
# 0.00037 0.00037599710894149464
# {'ETH-1': 0.2052, 'ETH-2': 0.2085, 'ETH-3': 0.6}

The two options above are equivalent, but the latter provides a simple way to compare different data processing choices:

from D47crunch import D47data

# create two D47data objects:
foo = D47data()
bar = D47data()

# modify foo in various ways:
foo.LAMBDA_17 = 0.52
foo.R17_VSMOW = 0.00037 # new value
foo.R17_VPDB = foo.R17_VSMOW * (foo.R18_VPDB/foo.R18_VSMOW) ** foo.LAMBDA_17
foo.Nominal_D47 = {
    'ETH-1': foo.Nominal_D47['ETH-1'],
    'ETH-2': foo.Nominal_D47['ETH-1'],
    'IAEA-C2': foo.Nominal_D47['IAEA-C2'],
    'INLAB_REF_MATERIAL': 0.666,
    }

# now import the same raw data into foo and bar:
foo.read('rawdata.csv')
foo.wg()          # compute δ13C, δ18O of working gas
foo.crunch()      # compute all δ13C, δ18O and raw Δ47 values
foo.standardize() # compute absolute Δ47 values

bar.read('rawdata.csv')
bar.wg()          # compute δ13C, δ18O of working gas
bar.crunch()      # compute all δ13C, δ18O and raw Δ47 values
bar.standardize() # compute absolute Δ47 values

# and compare the final results:
foo.table_of_samples(verbose = True, save_to_file = False)
bar.table_of_samples(verbose = True, save_to_file = False)

2.4 Process paired Δ47 and Δ48 values

Purely in terms of data processing, it is not obvious why Δ47 and Δ48 data should not be handled separately. For now, D47crunch uses two independent classes — D47data and D48data — which crunch numbers and deal with standardization in very similar ways. The following example demonstrates how to print out combined outputs for D47data and D48data.

from D47crunch import *

# generate virtual data:
args = dict(
    samples = [
        dict(Sample = 'ETH-1', N = 3),
        dict(Sample = 'ETH-2', N = 3),
        dict(Sample = 'ETH-3', N = 3),
        dict(Sample = 'FOO', N = 3,
            d13C_VPDB = -5., d18O_VPDB = -10.,
            D47 = 0.3, D48 = 0.15),
        ], rD47 = 0.010, rD48 = 0.030)

session1 = virtual_data(session = 'Session_01', **args)
session2 = virtual_data(session = 'Session_02', **args)

# create D47data instance:
data47 = D47data(session1 + session2)

# process D47data instance:
data47.crunch()
data47.standardize()

# create D48data instance:
data48 = D48data(data47) # alternatively: data48 = D48data(session1 + session2)

# process D48data instance:
data48.crunch()
data48.standardize()

# output combined results:
table_of_sessions(data47, data48)
table_of_samples(data47, data48)
table_of_analyses(data47, data48)

Expected output:

––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  –––––––––––––––  ––––––––––––––  ––––––  –––––––––––––  –––––––––––––––  ––––––––––––––
Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47      a_47 ± SE  1e3 x b_47 ± SE       c_47 ± SE   r_D48      a_48 ± SE  1e3 x b_48 ± SE       c_48 ± SE
––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  –––––––––––––––  ––––––––––––––  ––––––  –––––––––––––  –––––––––––––––  ––––––––––––––
Session_01   9   3       -4.000        26.000  0.0000  0.0000  0.0098  1.021 ± 0.019   -0.398 ± 0.260  -0.903 ± 0.006  0.0486  0.540 ± 0.151    1.235 ± 0.607  -0.390 ± 0.025
Session_02   9   3       -4.000        26.000  0.0000  0.0000  0.0090  1.015 ± 0.019    0.376 ± 0.260  -0.905 ± 0.006  0.0186  1.350 ± 0.156   -0.871 ± 0.608  -0.504 ± 0.027
––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  –––––––––––––––  ––––––––––––––  ––––––  –––––––––––––  –––––––––––––––  ––––––––––––––


––––––  –  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
Sample  N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene     D48      SE    95% CL      SD  p_Levene
––––––  –  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
ETH-1   6       2.02       37.02  0.2052                    0.0078            0.1380                    0.0223          
ETH-2   6     -10.17       19.88  0.2085                    0.0036            0.1380                    0.0482          
ETH-3   6       1.71       37.45  0.6132                    0.0080            0.2700                    0.0176          
FOO     6      -5.00       28.91  0.3026  0.0044  ± 0.0093  0.0121     0.164  0.1397  0.0121  ± 0.0255  0.0267     0.127
––––––  –  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––


–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––  ––––––––
UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47       D48
–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––  ––––––––
1    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.120787   21.286237   27.780042    2.020000   37.024281  -0.708176  -0.316435  -0.000013  0.197297  0.087763
2    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.132240   21.307795   27.780042    2.020000   37.024281  -0.696913  -0.295333  -0.000013  0.208328  0.126791
3    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.132438   21.313884   27.780042    2.020000   37.024281  -0.696718  -0.289374  -0.000013  0.208519  0.137813
4    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700300  -12.210735  -18.023381  -10.170000   19.875825  -0.683938  -0.297902  -0.000002  0.209785  0.198705
5    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.707421  -12.270781  -18.023381  -10.170000   19.875825  -0.691145  -0.358673  -0.000002  0.202726  0.086308
6    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700061  -12.278310  -18.023381  -10.170000   19.875825  -0.683696  -0.366292  -0.000002  0.210022  0.072215
7    Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.684379   22.225827   28.306614    1.710000   37.450394  -0.273094  -0.216392  -0.000014  0.623472  0.270873
8    Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.660163   22.233729   28.306614    1.710000   37.450394  -0.296906  -0.208664  -0.000014  0.600150  0.285167
9    Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.675191   22.215632   28.306614    1.710000   37.450394  -0.282128  -0.226363  -0.000014  0.614623  0.252432
10   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.328380    5.374933    4.665655   -5.000000   28.907344  -0.582131  -0.288924  -0.000006  0.314928  0.175105
11   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.302220    5.384454    4.665655   -5.000000   28.907344  -0.608241  -0.279457  -0.000006  0.289356  0.192614
12   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.322530    5.372841    4.665655   -5.000000   28.907344  -0.587970  -0.291004  -0.000006  0.309209  0.171257
13   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.140853   21.267202   27.780042    2.020000   37.024281  -0.688442  -0.335067  -0.000013  0.207730  0.138730
14   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.127087   21.256983   27.780042    2.020000   37.024281  -0.701980  -0.345071  -0.000013  0.194396  0.131311
15   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.148253   21.287779   27.780042    2.020000   37.024281  -0.681165  -0.314926  -0.000013  0.214898  0.153668
16   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715859  -12.204791  -18.023381  -10.170000   19.875825  -0.699685  -0.291887  -0.000002  0.207349  0.149128
17   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.709763  -12.188685  -18.023381  -10.170000   19.875825  -0.693516  -0.275587  -0.000002  0.213426  0.161217
18   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715427  -12.253049  -18.023381  -10.170000   19.875825  -0.699249  -0.340727  -0.000002  0.207780  0.112907
19   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.685994   22.249463   28.306614    1.710000   37.450394  -0.271506  -0.193275  -0.000014  0.618328  0.244431
20   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.681351   22.298166   28.306614    1.710000   37.450394  -0.276071  -0.145641  -0.000014  0.613831  0.279758
21   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.676169   22.306848   28.306614    1.710000   37.450394  -0.281167  -0.137150  -0.000014  0.608813  0.286056
22   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.324359    5.339497    4.665655   -5.000000   28.907344  -0.586144  -0.324160  -0.000006  0.314015  0.136535
23   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.297658    5.325854    4.665655   -5.000000   28.907344  -0.612794  -0.337727  -0.000006  0.287767  0.126473
24   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.310185    5.339898    4.665655   -5.000000   28.907344  -0.600291  -0.323761  -0.000006  0.300082  0.136830
–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––  ––––––––
<<<<<<< HEAD =======

API Documentation

>>>>>>> master
   1'''
   2Standardization and analytical error propagation of Δ47 and Δ48 clumped-isotope measurements
   3
   4Process and standardize carbonate and/or CO2 clumped-isotope analyses,
   5from low-level data out of a dual-inlet mass spectrometer to final, “absolute”
   6Δ47 and Δ48 values with fully propagated analytical error estimates
   7([Daëron, 2021](https://doi.org/10.1029/2020GC009592)).
   8
   9The **tutorial** section takes you through a series of simple steps to import/process data and print out the results.
  10The **how-to** section provides instructions applicable to various specific tasks.
  11
  12.. include:: ../docs/tutorial.md
  13.. include:: ../docs/howto.md
<<<<<<< HEAD
  14'''
  15
  16__docformat__ = "restructuredtext"
  17__author__    = 'Mathieu Daëron'
  18__contact__   = 'daeron@lsce.ipsl.fr'
  19__copyright__ = 'Copyright (c) 2023 Mathieu Daëron'
  20__license__   = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause'
  21__date__      = '2023-05-11'
  22__version__   = '2.0.4'
  23
  24import os
  25import numpy as np
  26from statistics import stdev
  27from scipy.stats import t as tstudent
  28from scipy.stats import levene
  29from scipy.interpolate import interp1d
  30from numpy import linalg
  31from lmfit import Minimizer, Parameters, report_fit
  32from matplotlib import pyplot as ppl
  33from datetime import datetime as dt
  34from functools import wraps
  35from colorsys import hls_to_rgb
  36from matplotlib import rcParams
  37
  38rcParams['font.family'] = 'sans-serif'
  39rcParams['font.sans-serif'] = 'Helvetica'
  40rcParams['font.size'] = 10
  41rcParams['mathtext.fontset'] = 'custom'
  42rcParams['mathtext.rm'] = 'sans'
  43rcParams['mathtext.bf'] = 'sans:bold'
  44rcParams['mathtext.it'] = 'sans:italic'
  45rcParams['mathtext.cal'] = 'sans:italic'
  46rcParams['mathtext.default'] = 'rm'
  47rcParams['xtick.major.size'] = 4
  48rcParams['xtick.major.width'] = 1
  49rcParams['ytick.major.size'] = 4
  50rcParams['ytick.major.width'] = 1
  51rcParams['axes.grid'] = False
  52rcParams['axes.linewidth'] = 1
  53rcParams['grid.linewidth'] = .75
  54rcParams['grid.linestyle'] = '-'
  55rcParams['grid.alpha'] = .15
  56rcParams['savefig.dpi'] = 150
  57
  58Petersen_etal_CO2eqD47 = np.array([[-12, 1.147113572], [-11, 1.139961218], [-10, 1.132872856], [-9, 1.125847677], [-8, 1.118884889], [-7, 1.111983708], [-6, 1.105143366], [-5, 1.098363105], [-4, 1.091642182], [-3, 1.084979862], [-2, 1.078375423], [-1, 1.071828156], [0, 1.065337360], [1, 1.058902349], [2, 1.052522443], [3, 1.046196976], [4, 1.039925291], [5, 1.033706741], [6, 1.027540690], [7, 1.021426510], [8, 1.015363585], [9, 1.009351306], [10, 1.003389075], [11, 0.997476303], [12, 0.991612409], [13, 0.985796821], [14, 0.980028975], [15, 0.974308318], [16, 0.968634304], [17, 0.963006392], [18, 0.957424055], [19, 0.951886769], [20, 0.946394020], [21, 0.940945302], [22, 0.935540114], [23, 0.930177964], [24, 0.924858369], [25, 0.919580851], [26, 0.914344938], [27, 0.909150167], [28, 0.903996080], [29, 0.898882228], [30, 0.893808167], [31, 0.888773459], [32, 0.883777672], [33, 0.878820382], [34, 0.873901170], [35, 0.869019623], [36, 0.864175334], [37, 0.859367901], [38, 0.854596929], [39, 0.849862028], [40, 0.845162813], [41, 0.840498905], [42, 0.835869931], [43, 0.831275522], [44, 0.826715314], [45, 0.822188950], [46, 0.817696075], [47, 0.813236341], [48, 0.808809404], [49, 0.804414926], [50, 0.800052572], [51, 0.795722012], [52, 0.791422922], [53, 0.787154979], [54, 0.782917869], [55, 0.778711277], [56, 0.774534898], [57, 0.770388426], [58, 0.766271562], [59, 0.762184010], [60, 0.758125479], [61, 0.754095680], [62, 0.750094329], [63, 0.746121147], [64, 0.742175856], [65, 0.738258184], [66, 0.734367860], [67, 0.730504620], [68, 0.726668201], [69, 0.722858343], [70, 0.719074792], [71, 0.715317295], [72, 0.711585602], [73, 0.707879469], [74, 0.704198652], [75, 0.700542912], [76, 0.696912012], [77, 0.693305719], [78, 0.689723802], [79, 0.686166034], [80, 0.682632189], [81, 0.679122047], [82, 0.675635387], [83, 0.672171994], [84, 0.668731654], [85, 0.665314156], [86, 0.661919291], [87, 0.658546854], [88, 0.655196641], [89, 0.651868451], [90, 0.648562087], [91, 0.645277352], [92, 0.642014054], [93, 0.638771999], [94, 0.635551001], [95, 0.632350872], [96, 0.629171428], [97, 0.626012487], [98, 0.622873870], [99, 0.619755397], [100, 0.616656895], [102, 0.610519107], [104, 0.604459143], [106, 0.598475670], [108, 0.592567388], [110, 0.586733026], [112, 0.580971342], [114, 0.575281125], [116, 0.569661187], [118, 0.564110371], [120, 0.558627545], [122, 0.553211600], [124, 0.547861454], [126, 0.542576048], [128, 0.537354347], [130, 0.532195337], [132, 0.527098028], [134, 0.522061450], [136, 0.517084654], [138, 0.512166711], [140, 0.507306712], [142, 0.502503768], [144, 0.497757006], [146, 0.493065573], [148, 0.488428634], [150, 0.483845370], [152, 0.479314980], [154, 0.474836677], [156, 0.470409692], [158, 0.466033271], [160, 0.461706674], [162, 0.457429176], [164, 0.453200067], [166, 0.449018650], [168, 0.444884242], [170, 0.440796174], [172, 0.436753787], [174, 0.432756438], [176, 0.428803494], [178, 0.424894334], [180, 0.421028350], [182, 0.417204944], [184, 0.413423530], [186, 0.409683531], [188, 0.405984383], [190, 0.402325531], [192, 0.398706429], [194, 0.395126543], [196, 0.391585347], [198, 0.388082324], [200, 0.384616967], [202, 0.381188778], [204, 0.377797268], [206, 0.374441954], [208, 0.371122364], [210, 0.367838033], [212, 0.364588505], [214, 0.361373329], [216, 0.358192065], [218, 0.355044277], [220, 0.351929540], [222, 0.348847432], [224, 0.345797540], [226, 0.342779460], [228, 0.339792789], [230, 0.336837136], [232, 0.333912113], [234, 0.331017339], [236, 0.328152439], [238, 0.325317046], [240, 0.322510795], [242, 0.319733329], [244, 0.316984297], [246, 0.314263352], [248, 0.311570153], [250, 0.308904364], [252, 0.306265654], [254, 0.303653699], [256, 0.301068176], [258, 0.298508771], [260, 0.295975171], [262, 0.293467070], [264, 0.290984167], [266, 0.288526163], [268, 0.286092765], [270, 0.283683684], [272, 0.281298636], [274, 0.278937339], [276, 0.276599517], [278, 0.274284898], [280, 0.271993211], [282, 0.269724193], [284, 0.267477582], [286, 0.265253121], [288, 0.263050554], [290, 0.260869633], [292, 0.258710110], [294, 0.256571741], [296, 0.254454286], [298, 0.252357508], [300, 0.250281174], [302, 0.248225053], [304, 0.246188917], [306, 0.244172542], [308, 0.242175707], [310, 0.240198194], [312, 0.238239786], [314, 0.236300272], [316, 0.234379441], [318, 0.232477087], [320, 0.230593005], [322, 0.228726993], [324, 0.226878853], [326, 0.225048388], [328, 0.223235405], [330, 0.221439711], [332, 0.219661118], [334, 0.217899439], [336, 0.216154491], [338, 0.214426091], [340, 0.212714060], [342, 0.211018220], [344, 0.209338398], [346, 0.207674420], [348, 0.206026115], [350, 0.204393315], [355, 0.200378063], [360, 0.196456139], [365, 0.192625077], [370, 0.188882487], [375, 0.185226048], [380, 0.181653511], [385, 0.178162694], [390, 0.174751478], [395, 0.171417807], [400, 0.168159686], [405, 0.164975177], [410, 0.161862398], [415, 0.158819521], [420, 0.155844772], [425, 0.152936426], [430, 0.150092806], [435, 0.147312286], [440, 0.144593281], [445, 0.141934254], [450, 0.139333710], [455, 0.136790195], [460, 0.134302294], [465, 0.131868634], [470, 0.129487876], [475, 0.127158722], [480, 0.124879906], [485, 0.122650197], [490, 0.120468398], [495, 0.118333345], [500, 0.116243903], [505, 0.114198970], [510, 0.112197471], [515, 0.110238362], [520, 0.108320625], [525, 0.106443271], [530, 0.104605335], [535, 0.102805877], [540, 0.101043985], [545, 0.099318768], [550, 0.097629359], [555, 0.095974915], [560, 0.094354612], [565, 0.092767650], [570, 0.091213248], [575, 0.089690648], [580, 0.088199108], [585, 0.086737906], [590, 0.085306341], [595, 0.083903726], [600, 0.082529395], [605, 0.081182697], [610, 0.079862998], [615, 0.078569680], [620, 0.077302141], [625, 0.076059794], [630, 0.074842066], [635, 0.073648400], [640, 0.072478251], [645, 0.071331090], [650, 0.070206399], [655, 0.069103674], [660, 0.068022424], [665, 0.066962168], [670, 0.065922439], [675, 0.064902780], [680, 0.063902748], [685, 0.062921909], [690, 0.061959837], [695, 0.061016122], [700, 0.060090360], [705, 0.059182157], [710, 0.058291131], [715, 0.057416907], [720, 0.056559120], [725, 0.055717414], [730, 0.054891440], [735, 0.054080860], [740, 0.053285343], [745, 0.052504565], [750, 0.051738210], [755, 0.050985971], [760, 0.050247546], [765, 0.049522643], [770, 0.048810974], [775, 0.048112260], [780, 0.047426227], [785, 0.046752609], [790, 0.046091145], [795, 0.045441581], [800, 0.044803668], [805, 0.044177164], [810, 0.043561831], [815, 0.042957438], [820, 0.042363759], [825, 0.041780573], [830, 0.041207664], [835, 0.040644822], [840, 0.040091839], [845, 0.039548516], [850, 0.039014654], [855, 0.038490063], [860, 0.037974554], [865, 0.037467944], [870, 0.036970054], [875, 0.036480707], [880, 0.035999734], [885, 0.035526965], [890, 0.035062238], [895, 0.034605393], [900, 0.034156272], [905, 0.033714724], [910, 0.033280598], [915, 0.032853749], [920, 0.032434032], [925, 0.032021309], [930, 0.031615443], [935, 0.031216300], [940, 0.030823749], [945, 0.030437663], [950, 0.030057915], [955, 0.029684385], [960, 0.029316951], [965, 0.028955498], [970, 0.028599910], [975, 0.028250075], [980, 0.027905884], [985, 0.027567229], [990, 0.027234006], [995, 0.026906112], [1000, 0.026583445], [1005, 0.026265908], [1010, 0.025953405], [1015, 0.025645841], [1020, 0.025343124], [1025, 0.025045163], [1030, 0.024751871], [1035, 0.024463160], [1040, 0.024178947], [1045, 0.023899147], [1050, 0.023623680], [1055, 0.023352467], [1060, 0.023085429], [1065, 0.022822491], [1070, 0.022563577], [1075, 0.022308615], [1080, 0.022057533], [1085, 0.021810260], [1090, 0.021566729], [1095, 0.021326872], [1100, 0.021090622]])
  59_fCO2eqD47_Petersen = interp1d(Petersen_etal_CO2eqD47[:,0], Petersen_etal_CO2eqD47[:,1])
  60def fCO2eqD47_Petersen(T):
  61	'''
  62	CO2 equilibrium Δ47 value as a function of T (in degrees C)
  63	according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127).
  64
  65	'''
  66	return float(_fCO2eqD47_Petersen(T))
  67
  68
  69Wang_etal_CO2eqD47 = np.array([[-83., 1.8954], [-73., 1.7530], [-63., 1.6261], [-53., 1.5126], [-43., 1.4104], [-33., 1.3182], [-23., 1.2345], [-13., 1.1584], [-3., 1.0888], [7., 1.0251], [17., 0.9665], [27., 0.9125], [37., 0.8626], [47., 0.8164], [57., 0.7734], [67., 0.7334], [87., 0.6612], [97., 0.6286], [107., 0.5980], [117., 0.5693], [127., 0.5423], [137., 0.5169], [147., 0.4930], [157., 0.4704], [167., 0.4491], [177., 0.4289], [187., 0.4098], [197., 0.3918], [207., 0.3747], [217., 0.3585], [227., 0.3431], [237., 0.3285], [247., 0.3147], [257., 0.3015], [267., 0.2890], [277., 0.2771], [287., 0.2657], [297., 0.2550], [307., 0.2447], [317., 0.2349], [327., 0.2256], [337., 0.2167], [347., 0.2083], [357., 0.2002], [367., 0.1925], [377., 0.1851], [387., 0.1781], [397., 0.1714], [407., 0.1650], [417., 0.1589], [427., 0.1530], [437., 0.1474], [447., 0.1421], [457., 0.1370], [467., 0.1321], [477., 0.1274], [487., 0.1229], [497., 0.1186], [507., 0.1145], [517., 0.1105], [527., 0.1068], [537., 0.1031], [547., 0.0997], [557., 0.0963], [567., 0.0931], [577., 0.0901], [587., 0.0871], [597., 0.0843], [607., 0.0816], [617., 0.0790], [627., 0.0765], [637., 0.0741], [647., 0.0718], [657., 0.0695], [667., 0.0674], [677., 0.0654], [687., 0.0634], [697., 0.0615], [707., 0.0597], [717., 0.0579], [727., 0.0562], [737., 0.0546], [747., 0.0530], [757., 0.0515], [767., 0.0500], [777., 0.0486], [787., 0.0472], [797., 0.0459], [807., 0.0447], [817., 0.0435], [827., 0.0423], [837., 0.0411], [847., 0.0400], [857., 0.0390], [867., 0.0380], [877., 0.0370], [887., 0.0360], [897., 0.0351], [907., 0.0342], [917., 0.0333], [927., 0.0325], [937., 0.0317], [947., 0.0309], [957., 0.0302], [967., 0.0294], [977., 0.0287], [987., 0.0281], [997., 0.0274], [1007., 0.0268], [1017., 0.0261], [1027., 0.0255], [1037., 0.0249], [1047., 0.0244], [1057., 0.0238], [1067., 0.0233], [1077., 0.0228], [1087., 0.0223], [1097., 0.0218]])
  70_fCO2eqD47_Wang = interp1d(Wang_etal_CO2eqD47[:,0] - 0.15, Wang_etal_CO2eqD47[:,1])
  71def fCO2eqD47_Wang(T):
  72	'''
  73	CO2 equilibrium Δ47 value as a function of `T` (in degrees C)
  74	according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039)
  75	(supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)).
  76	'''
  77	return float(_fCO2eqD47_Wang(T))
  78
  79
  80def correlated_sum(X, C, w = None):
  81	'''
  82	Compute covariance-aware linear combinations
  83
  84	**Parameters**
  85	
  86	+ `X`: list or 1-D array of values to sum
  87	+ `C`: covariance matrix for the elements of `X`
  88	+ `w`: list or 1-D array of weights to apply to the elements of `X`
  89	       (all equal to 1 by default)
  90
  91	Return the sum (and its SE) of the elements of `X`, with optional weights equal
  92	to the elements of `w`, accounting for covariances between the elements of `X`.
  93	'''
  94	if w is None:
  95		w = [1 for x in X]
  96	return np.dot(w,X), (np.dot(w,np.dot(C,w)))**.5
  97
  98
  99def make_csv(x, hsep = ',', vsep = '\n'):
 100	'''
 101	Formats a list of lists of strings as a CSV
 102
 103	**Parameters**
 104
 105	+ `x`: the list of lists of strings to format
 106	+ `hsep`: the field separator (`,` by default)
 107	+ `vsep`: the line-ending convention to use (`\\n` by default)
 108
 109	**Example**
 110
 111	```py
 112	print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']]))
 113	```
 114
 115	outputs:
 116
 117	```py
 118	a,b,c
 119	d,e,f
 120	```
 121	'''
 122	return vsep.join([hsep.join(l) for l in x])
 123
 124
 125def pf(txt):
 126	'''
 127	Modify string `txt` to follow `lmfit.Parameter()` naming rules.
 128	'''
 129	return txt.replace('-','_').replace('.','_').replace(' ','_')
 130
 131
 132def smart_type(x):
 133	'''
 134	Tries to convert string `x` to a float if it includes a decimal point, or
 135	to an integer if it does not. If both attempts fail, return the original
 136	string unchanged.
 137	'''
 138	try:
 139		y = float(x)
 140	except ValueError:
 141		return x
 142	if '.' not in x:
 143		return int(y)
 144	return y
 145
 146
 147def pretty_table(x, header = 1, hsep = '  ', vsep = '–', align = '<'):
 148	'''
 149	Reads a list of lists of strings and outputs an ascii table
 150
 151	**Parameters**
 152
 153	+ `x`: a list of lists of strings
 154	+ `header`: the number of lines to treat as header lines
 155	+ `hsep`: the horizontal separator between columns
 156	+ `vsep`: the character to use as vertical separator
 157	+ `align`: string of left (`<`) or right (`>`) alignment characters.
 158
 159	**Example**
 160
 161	```py
 162	x = [['A', 'B', 'C'], ['1', '1.9999', 'foo'], ['10', 'x', 'bar']]
 163	print(pretty_table(x))
 164	```
 165	yields:	
 166	```
 167	--  ------  ---
 168	A        B    C
 169	--  ------  ---
 170	1   1.9999  foo
 171	10       x  bar
 172	--  ------  ---
 173	```
 174	
 175	'''
 176	txt = []
 177	widths = [np.max([len(e) for e in c]) for c in zip(*x)]
 178
 179	if len(widths) > len(align):
 180		align += '>' * (len(widths)-len(align))
 181	sepline = hsep.join([vsep*w for w in widths])
 182	txt += [sepline]
 183	for k,l in enumerate(x):
 184		if k and k == header:
 185			txt += [sepline]
 186		txt += [hsep.join([f'{e:{a}{w}}' for e, w, a in zip(l, widths, align)])]
 187	txt += [sepline]
 188	txt += ['']
 189	return '\n'.join(txt)
 190
 191
 192def transpose_table(x):
 193	'''
 194	Transpose a list if lists
 195
 196	**Parameters**
 197
 198	+ `x`: a list of lists
 199
 200	**Example**
 201
 202	```py
 203	x = [[1, 2], [3, 4]]
 204	print(transpose_table(x)) # yields: [[1, 3], [2, 4]]
 205	```
 206	'''
 207	return [[e for e in c] for c in zip(*x)]
 208
 209
 210def w_avg(X, sX) :
 211	'''
 212	Compute variance-weighted average
 213
 214	Returns the value and SE of the weighted average of the elements of `X`,
 215	with relative weights equal to their inverse variances (`1/sX**2`).
 216
 217	**Parameters**
 218
 219	+ `X`: array-like of elements to average
 220	+ `sX`: array-like of the corresponding SE values
 221
 222	**Tip**
 223
 224	If `X` and `sX` are initially arranged as a list of `(x, sx)` doublets,
 225	they may be rearranged using `zip()`:
 226
 227	```python
 228	foo = [(0, 1), (1, 0.5), (2, 0.5)]
 229	print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333)
 230	```
 231	'''
 232	X = [ x for x in X ]
 233	sX = [ sx for sx in sX ]
 234	W = [ sx**-2 for sx in sX ]
 235	W = [ w/sum(W) for w in W ]
 236	Xavg = sum([ w*x for w,x in zip(W,X) ])
 237	sXavg = sum([ w**2*sx**2 for w,sx in zip(W,sX) ])**.5
 238	return Xavg, sXavg
 239
 240
 241def read_csv(filename, sep = ''):
 242	'''
 243	Read contents of `filename` in csv format and return a list of dictionaries.
 244
 245	In the csv string, spaces before and after field separators (`','` by default)
 246	are optional.
 247
 248	**Parameters**
 249
 250	+ `filename`: the csv file to read
 251	+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
 252	whichever appers most often in the contents of `filename`.
 253	'''
 254	with open(filename) as fid:
 255		txt = fid.read()
 256
 257	if sep == '':
 258		sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
 259	txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
 260	return [{k: smart_type(v) for k,v in zip(txt[0], l) if v} for l in txt[1:]]
 261
 262
 263def simulate_single_analysis(
 264	sample = 'MYSAMPLE',
 265	d13Cwg_VPDB = -4., d18Owg_VSMOW = 26.,
 266	d13C_VPDB = None, d18O_VPDB = None,
 267	D47 = None, D48 = None, D49 = 0., D17O = 0.,
 268	a47 = 1., b47 = 0., c47 = -0.9,
 269	a48 = 1., b48 = 0., c48 = -0.45,
 270	Nominal_D47 = None,
 271	Nominal_D48 = None,
 272	Nominal_d13C_VPDB = None,
 273	Nominal_d18O_VPDB = None,
 274	ALPHA_18O_ACID_REACTION = None,
 275	R13_VPDB = None,
 276	R17_VSMOW = None,
 277	R18_VSMOW = None,
 278	LAMBDA_17 = None,
 279	R18_VPDB = None,
 280	):
 281	'''
 282	Compute working-gas delta values for a single analysis, assuming a stochastic working
 283	gas and a “perfect” measurement (i.e. raw Δ values are identical to absolute values).
 284	
 285	**Parameters**
 286
 287	+ `sample`: sample name
 288	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
 289		(respectively –4 and +26 ‰ by default)
 290	+ `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
 291	+ `D47`, `D48`, `D49`, `D17O`: clumped-isotope and oxygen-17 anomalies
 292		of the carbonate sample
 293	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and
 294		Δ48 values if `D47` or `D48` are not specified
 295	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
 296		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
 297	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
 298	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
 299		correction parameters (by default equal to the `D4xdata` default values)
 300	
 301	Returns a dictionary with fields
 302	`['Sample', 'D17O', 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'd45', 'd46', 'd47', 'd48', 'd49']`.
 303	'''
 304
 305	if Nominal_d13C_VPDB is None:
 306		Nominal_d13C_VPDB = D4xdata().Nominal_d13C_VPDB
 307
 308	if Nominal_d18O_VPDB is None:
 309		Nominal_d18O_VPDB = D4xdata().Nominal_d18O_VPDB
 310
 311	if ALPHA_18O_ACID_REACTION is None:
 312		ALPHA_18O_ACID_REACTION = D4xdata().ALPHA_18O_ACID_REACTION
 313
 314	if R13_VPDB is None:
 315		R13_VPDB = D4xdata().R13_VPDB
 316
 317	if R17_VSMOW is None:
 318		R17_VSMOW = D4xdata().R17_VSMOW
 319
 320	if R18_VSMOW is None:
 321		R18_VSMOW = D4xdata().R18_VSMOW
 322
 323	if LAMBDA_17 is None:
 324		LAMBDA_17 = D4xdata().LAMBDA_17
 325
 326	if R18_VPDB is None:
 327		R18_VPDB = D4xdata().R18_VPDB
 328	
 329	R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW) ** LAMBDA_17
 330	
 331	if Nominal_D47 is None:
 332		Nominal_D47 = D47data().Nominal_D47
 333
 334	if Nominal_D48 is None:
 335		Nominal_D48 = D48data().Nominal_D48
 336	
 337	if d13C_VPDB is None:
 338		if sample in Nominal_d13C_VPDB:
 339			d13C_VPDB = Nominal_d13C_VPDB[sample]
 340		else:
 341			raise KeyError(f"Sample {sample} is missing d13C_VDP value, and it is not defined in Nominal_d13C_VDP.")
 342
 343	if d18O_VPDB is None:
 344		if sample in Nominal_d18O_VPDB:
 345			d18O_VPDB = Nominal_d18O_VPDB[sample]
 346		else:
 347			raise KeyError(f"Sample {sample} is missing d18O_VPDB value, and it is not defined in Nominal_d18O_VPDB.")
 348
 349	if D47 is None:
 350		if sample in Nominal_D47:
 351			D47 = Nominal_D47[sample]
 352		else:
 353			raise KeyError(f"Sample {sample} is missing D47 value, and it is not defined in Nominal_D47.")
 354
 355	if D48 is None:
 356		if sample in Nominal_D48:
 357			D48 = Nominal_D48[sample]
 358		else:
 359			raise KeyError(f"Sample {sample} is missing D48 value, and it is not defined in Nominal_D48.")
 360
 361	X = D4xdata()
 362	X.R13_VPDB = R13_VPDB
 363	X.R17_VSMOW = R17_VSMOW
 364	X.R18_VSMOW = R18_VSMOW
 365	X.LAMBDA_17 = LAMBDA_17
 366	X.R18_VPDB = R18_VPDB
 367	X.R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW)**LAMBDA_17
 368
 369	R45wg, R46wg, R47wg, R48wg, R49wg = X.compute_isobar_ratios(
 370		R13 = R13_VPDB * (1 + d13Cwg_VPDB/1000),
 371		R18 = R18_VSMOW * (1 + d18Owg_VSMOW/1000),
 372		)
 373	R45, R46, R47, R48, R49 = X.compute_isobar_ratios(
 374		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
 375		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
 376		D17O=D17O, D47=D47, D48=D48, D49=D49,
 377		)
 378	R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = X.compute_isobar_ratios(
 379		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
 380		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
 381		D17O=D17O,
 382		)
 383	
 384	d45 = 1000 * (R45/R45wg - 1)
 385	d46 = 1000 * (R46/R46wg - 1)
 386	d47 = 1000 * (R47/R47wg - 1)
 387	d48 = 1000 * (R48/R48wg - 1)
 388	d49 = 1000 * (R49/R49wg - 1)
 389
 390	for k in range(3): # dumb iteration to adjust for small changes in d47
 391		R47raw = (1 + (a47 * D47 + b47 * d47 + c47)/1000) * R47stoch
 392		R48raw = (1 + (a48 * D48 + b48 * d48 + c48)/1000) * R48stoch	
 393		d47 = 1000 * (R47raw/R47wg - 1)
 394		d48 = 1000 * (R48raw/R48wg - 1)
 395
 396	return dict(
 397		Sample = sample,
 398		D17O = D17O,
 399		d13Cwg_VPDB = d13Cwg_VPDB,
 400		d18Owg_VSMOW = d18Owg_VSMOW,
 401		d45 = d45,
 402		d46 = d46,
 403		d47 = d47,
 404		d48 = d48,
 405		d49 = d49,
 406		)
 407
 408
 409def virtual_data(
 410	samples = [],
 411	a47 = 1., b47 = 0., c47 = -0.9,
 412	a48 = 1., b48 = 0., c48 = -0.45,
 413	rD47 = 0.015, rD48 = 0.045,
 414	d13Cwg_VPDB = None, d18Owg_VSMOW = None,
 415	session = None,
 416	Nominal_D47 = None, Nominal_D48 = None,
 417	Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None,
 418	ALPHA_18O_ACID_REACTION = None,
 419	R13_VPDB = None,
 420	R17_VSMOW = None,
 421	R18_VSMOW = None,
 422	LAMBDA_17 = None,
 423	R18_VPDB = None,
 424	seed = 0,
 425	):
 426	'''
 427	Return list with simulated analyses from a single session.
 428	
 429	**Parameters**
 430	
 431	+ `samples`: a list of entries; each entry is a dictionary with the following fields:
 432	    * `Sample`: the name of the sample
 433	    * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
 434	    * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
 435	    * `N`: how many analyses to generate for this sample
 436	+ `a47`: scrambling factor for Δ47
 437	+ `b47`: compositional nonlinearity for Δ47
 438	+ `c47`: working gas offset for Δ47
 439	+ `a48`: scrambling factor for Δ48
 440	+ `b48`: compositional nonlinearity for Δ48
 441	+ `c48`: working gas offset for Δ48
 442	+ `rD47`: analytical repeatability of Δ47
 443	+ `rD48`: analytical repeatability of Δ48
 444	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
 445		(by default equal to the `simulate_single_analysis` default values)
 446	+ `session`: name of the session (no name by default)
 447	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values
 448		if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
 449	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
 450		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified 
 451		(by default equal to the `simulate_single_analysis` defaults)
 452	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
 453		(by default equal to the `simulate_single_analysis` defaults)
 454	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
 455		correction parameters (by default equal to the `simulate_single_analysis` default)
 456	+ `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations
 457	
 458		
 459	Here is an example of using this method to generate an arbitrary combination of
 460	anchors and unknowns for a bunch of sessions:
 461
 462	```py
 463	args = dict(
 464		samples = [
 465			dict(Sample = 'ETH-1', N = 4),
 466			dict(Sample = 'ETH-2', N = 5),
 467			dict(Sample = 'ETH-3', N = 6),
 468			dict(Sample = 'FOO', N = 2,
 469				d13C_VPDB = -5., d18O_VPDB = -10.,
 470				D47 = 0.3, D48 = 0.15),
 471			], rD47 = 0.010, rD48 = 0.030)
 472
 473	session1 = virtual_data(session = 'Session_01', **args, seed = 123)
 474	session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
 475	session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
 476	session4 = virtual_data(session = 'Session_04', **args, seed = 123456)
 477
 478	D = D47data(session1 + session2 + session3 + session4)
 479
 480	D.crunch()
 481	D.standardize()
 482
 483	D.table_of_sessions(verbose = True, save_to_file = False)
 484	D.table_of_samples(verbose = True, save_to_file = False)
 485	D.table_of_analyses(verbose = True, save_to_file = False)
 486	```
 487	
 488	This should output something like:
 489	
 490	```
 491	[table_of_sessions] 
 492	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
 493	Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47         a ± SE    1e3 x b ± SE          c ± SE
 494	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
 495	Session_01  15   2       -4.000        26.000  0.0000  0.0000  0.0110  0.997 ± 0.017  -0.097 ± 0.244  -0.896 ± 0.006
 496	Session_02  15   2       -4.000        26.000  0.0000  0.0000  0.0109  1.002 ± 0.017  -0.110 ± 0.244  -0.901 ± 0.006
 497	Session_03  15   2       -4.000        26.000  0.0000  0.0000  0.0107  1.010 ± 0.017  -0.037 ± 0.244  -0.904 ± 0.006
 498	Session_04  15   2       -4.000        26.000  0.0000  0.0000  0.0106  1.001 ± 0.017  -0.181 ± 0.244  -0.894 ± 0.006
 499	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
 500
 501	[table_of_samples] 
 502	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
 503	Sample   N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
 504	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
 505	ETH-1   16       2.02       37.02  0.2052                    0.0079          
 506	ETH-2   20     -10.17       19.88  0.2085                    0.0100          
 507	ETH-3   24       1.71       37.45  0.6132                    0.0105          
 508	FOO      8      -5.00       28.91  0.2989  0.0040  ± 0.0080  0.0101     0.638
 509	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
 510
 511	[table_of_analyses] 
 512	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
 513	UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
 514	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
 515	1    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.122986   21.273526   27.780042    2.020000   37.024281  -0.706013  -0.328878  -0.000013  0.192554
 516	2    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.130144   21.282615   27.780042    2.020000   37.024281  -0.698974  -0.319981  -0.000013  0.199615
 517	3    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.149219   21.299572   27.780042    2.020000   37.024281  -0.680215  -0.303383  -0.000013  0.218429
 518	4    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.136616   21.233128   27.780042    2.020000   37.024281  -0.692609  -0.368421  -0.000013  0.205998
 519	5    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.697171  -12.203054  -18.023381  -10.170000   19.875825  -0.680771  -0.290128  -0.000002  0.215054
 520	6    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701124  -12.184422  -18.023381  -10.170000   19.875825  -0.684772  -0.271272  -0.000002  0.211041
 521	7    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715105  -12.195251  -18.023381  -10.170000   19.875825  -0.698923  -0.282232  -0.000002  0.196848
 522	8    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701529  -12.204963  -18.023381  -10.170000   19.875825  -0.685182  -0.292061  -0.000002  0.210630
 523	9    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.711420  -12.228478  -18.023381  -10.170000   19.875825  -0.695193  -0.315859  -0.000002  0.200589
 524	10   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.666719   22.296486   28.306614    1.710000   37.450394  -0.290459  -0.147284  -0.000014  0.609363
 525	11   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.671553   22.291060   28.306614    1.710000   37.450394  -0.285706  -0.152592  -0.000014  0.614130
 526	12   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.652854   22.273271   28.306614    1.710000   37.450394  -0.304093  -0.169990  -0.000014  0.595689
 527	13   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.684168   22.263156   28.306614    1.710000   37.450394  -0.273302  -0.179883  -0.000014  0.626572
 528	14   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.662702   22.253578   28.306614    1.710000   37.450394  -0.294409  -0.189251  -0.000014  0.605401
 529	15   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.681957   22.230907   28.306614    1.710000   37.450394  -0.275476  -0.211424  -0.000014  0.624391
 530	16   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.312044    5.395798    4.665655   -5.000000   28.907344  -0.598436  -0.268176  -0.000006  0.298996
 531	17   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.328123    5.307086    4.665655   -5.000000   28.907344  -0.582387  -0.356389  -0.000006  0.315092
 532	18   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.122201   21.340606   27.780042    2.020000   37.024281  -0.706785  -0.263217  -0.000013  0.195135
 533	19   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.134868   21.305714   27.780042    2.020000   37.024281  -0.694328  -0.297370  -0.000013  0.207564
 534	20   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.140008   21.261931   27.780042    2.020000   37.024281  -0.689273  -0.340227  -0.000013  0.212607
 535	21   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.135540   21.298472   27.780042    2.020000   37.024281  -0.693667  -0.304459  -0.000013  0.208224
 536	22   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701213  -12.202602  -18.023381  -10.170000   19.875825  -0.684862  -0.289671  -0.000002  0.213842
 537	23   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.685649  -12.190405  -18.023381  -10.170000   19.875825  -0.669108  -0.277327  -0.000002  0.229559
 538	24   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.719003  -12.257955  -18.023381  -10.170000   19.875825  -0.702869  -0.345692  -0.000002  0.195876
 539	25   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700592  -12.204641  -18.023381  -10.170000   19.875825  -0.684233  -0.291735  -0.000002  0.214469
 540	26   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720426  -12.214561  -18.023381  -10.170000   19.875825  -0.704308  -0.301774  -0.000002  0.194439
 541	27   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.673044   22.262090   28.306614    1.710000   37.450394  -0.284240  -0.180926  -0.000014  0.616730
 542	28   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.666542   22.263401   28.306614    1.710000   37.450394  -0.290634  -0.179643  -0.000014  0.610350
 543	29   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.680487   22.243486   28.306614    1.710000   37.450394  -0.276921  -0.199121  -0.000014  0.624031
 544	30   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.663900   22.245175   28.306614    1.710000   37.450394  -0.293231  -0.197469  -0.000014  0.607759
 545	31   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.674379   22.301309   28.306614    1.710000   37.450394  -0.282927  -0.142568  -0.000014  0.618039
 546	32   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.660825   22.270466   28.306614    1.710000   37.450394  -0.296255  -0.172733  -0.000014  0.604742
 547	33   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.294076    5.349940    4.665655   -5.000000   28.907344  -0.616369  -0.313776  -0.000006  0.283707
 548	34   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.313775    5.292121    4.665655   -5.000000   28.907344  -0.596708  -0.371269  -0.000006  0.303323
 549	35   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.121613   21.259909   27.780042    2.020000   37.024281  -0.707364  -0.342207  -0.000013  0.194934
 550	36   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.145714   21.304889   27.780042    2.020000   37.024281  -0.683661  -0.298178  -0.000013  0.218401
 551	37   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.126573   21.325093   27.780042    2.020000   37.024281  -0.702485  -0.278401  -0.000013  0.199764
 552	38   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.132057   21.323211   27.780042    2.020000   37.024281  -0.697092  -0.280244  -0.000013  0.205104
 553	39   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.708448  -12.232023  -18.023381  -10.170000   19.875825  -0.692185  -0.319447  -0.000002  0.208915
 554	40   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.714417  -12.202504  -18.023381  -10.170000   19.875825  -0.698226  -0.289572  -0.000002  0.202934
 555	41   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720039  -12.264469  -18.023381  -10.170000   19.875825  -0.703917  -0.352285  -0.000002  0.197300
 556	42   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701953  -12.228550  -18.023381  -10.170000   19.875825  -0.685611  -0.315932  -0.000002  0.215423
 557	43   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.704535  -12.213634  -18.023381  -10.170000   19.875825  -0.688224  -0.300836  -0.000002  0.212837
 558	44   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.652920   22.230043   28.306614    1.710000   37.450394  -0.304028  -0.212269  -0.000014  0.594265
 559	45   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.691485   22.261017   28.306614    1.710000   37.450394  -0.266106  -0.181975  -0.000014  0.631810
 560	46   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.679119   22.305357   28.306614    1.710000   37.450394  -0.278266  -0.138609  -0.000014  0.619771
 561	47   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.663623   22.327286   28.306614    1.710000   37.450394  -0.293503  -0.117161  -0.000014  0.604685
 562	48   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.678524   22.282103   28.306614    1.710000   37.450394  -0.278851  -0.161352  -0.000014  0.619192
 563	49   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.666246   22.283361   28.306614    1.710000   37.450394  -0.290925  -0.160121  -0.000014  0.607238
 564	50   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.309929    5.340249    4.665655   -5.000000   28.907344  -0.600546  -0.323413  -0.000006  0.300148
 565	51   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.317548    5.334102    4.665655   -5.000000   28.907344  -0.592942  -0.329524  -0.000006  0.307676
 566	52   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.136865   21.300298   27.780042    2.020000   37.024281  -0.692364  -0.302672  -0.000013  0.204033
 567	53   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.133538   21.291260   27.780042    2.020000   37.024281  -0.695637  -0.311519  -0.000013  0.200762
 568	54   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.139991   21.319865   27.780042    2.020000   37.024281  -0.689290  -0.283519  -0.000013  0.207107
 569	55   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.145748   21.330075   27.780042    2.020000   37.024281  -0.683629  -0.273524  -0.000013  0.212766
 570	56   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702989  -12.202762  -18.023381  -10.170000   19.875825  -0.686660  -0.289833  -0.000002  0.204507
 571	57   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.692830  -12.240287  -18.023381  -10.170000   19.875825  -0.676377  -0.327811  -0.000002  0.214786
 572	58   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702899  -12.180291  -18.023381  -10.170000   19.875825  -0.686568  -0.267091  -0.000002  0.204598
 573	59   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.709282  -12.282257  -18.023381  -10.170000   19.875825  -0.693029  -0.370287  -0.000002  0.198140
 574	60   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.679330  -12.235994  -18.023381  -10.170000   19.875825  -0.662712  -0.323466  -0.000002  0.228446
 575	61   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.695594   22.238663   28.306614    1.710000   37.450394  -0.262066  -0.203838  -0.000014  0.634200
 576	62   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.663504   22.286354   28.306614    1.710000   37.450394  -0.293620  -0.157194  -0.000014  0.602656
 577	63   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666457   22.254290   28.306614    1.710000   37.450394  -0.290717  -0.188555  -0.000014  0.605558
 578	64   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666910   22.223232   28.306614    1.710000   37.450394  -0.290271  -0.218930  -0.000014  0.606004
 579	65   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.679662   22.257256   28.306614    1.710000   37.450394  -0.277732  -0.185653  -0.000014  0.618539
 580	66   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.676768   22.267680   28.306614    1.710000   37.450394  -0.280578  -0.175459  -0.000014  0.615693
 581	67   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.307663    5.317330    4.665655   -5.000000   28.907344  -0.602808  -0.346202  -0.000006  0.290853
 582	68   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.308562    5.331400    4.665655   -5.000000   28.907344  -0.601911  -0.332212  -0.000006  0.291749
 583	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
 584	```
 585	'''
 586	
 587	kwargs = locals().copy()
 588
 589	from numpy import random as nprandom
 590	if seed:
 591		rng = nprandom.default_rng(seed)
 592	else:
 593		rng = nprandom.default_rng()
 594	
 595	N = sum([s['N'] for s in samples])
 596	errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
 597	errors47 *= rD47 / stdev(errors47) # scale errors to rD47
 598	errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
 599	errors48 *= rD48 / stdev(errors48) # scale errors to rD48
 600	
 601	k = 0
 602	out = []
 603	for s in samples:
 604		kw = {}
 605		kw['sample'] = s['Sample']
 606		kw = {
 607			**kw,
 608			**{var: kwargs[var]
 609				for var in [
 610					'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION',
 611					'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB',
 612					'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB',
 613					'a47', 'b47', 'c47', 'a48', 'b48', 'c48',
 614					]
 615				if kwargs[var] is not None},
 616			**{var: s[var]
 617				for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O']
 618				if var in s},
 619			}
 620
 621		sN = s['N']
 622		while sN:
 623			out.append(simulate_single_analysis(**kw))
 624			out[-1]['d47'] += errors47[k] * a47
 625			out[-1]['d48'] += errors48[k] * a48
 626			sN -= 1
 627			k += 1
 628
 629		if session is not None:
 630			for r in out:
 631				r['Session'] = session
 632	return out
 633
 634def table_of_samples(
 635	data47 = None,
 636	data48 = None,
 637	dir = 'output',
 638	filename = None,
 639	save_to_file = True,
 640	print_out = True,
 641	output = None,
 642	):
 643	'''
 644	Print out, save to disk and/or return a combined table of samples
 645	for a pair of `D47data` and `D48data` objects.
 646
 647	**Parameters**
 648
 649	+ `data47`: `D47data` instance
 650	+ `data48`: `D48data` instance
 651	+ `dir`: the directory in which to save the table
 652	+ `filename`: the name to the csv file to write to
 653	+ `save_to_file`: whether to save the table to disk
 654	+ `print_out`: whether to print out the table
 655	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
 656		if set to `'raw'`: return a list of list of strings
 657		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
 658	'''
 659	if data47 is None:
 660		if data48 is None:
 661			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
 662		else:
 663			return data48.table_of_samples(
 664				dir = dir,
 665				filename = filename,
 666				save_to_file = save_to_file,
 667				print_out = print_out,
 668				output = output
 669				)
 670	else:
 671		if data48 is None:
 672			return data47.table_of_samples(
 673				dir = dir,
 674				filename = filename,
 675				save_to_file = save_to_file,
 676				print_out = print_out,
 677				output = output
 678				)
 679		else:
 680			out47 = data47.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
 681			out48 = data48.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
 682			out = transpose_table(transpose_table(out47) + transpose_table(out48)[4:])
 683
 684			if save_to_file:
 685				if not os.path.exists(dir):
 686					os.makedirs(dir)
 687				if filename is None:
 688					filename = f'D47D48_samples.csv'
 689				with open(f'{dir}/{filename}', 'w') as fid:
 690					fid.write(make_csv(out))
 691			if print_out:
 692				print('\n'+pretty_table(out))
 693			if output == 'raw':
 694				return out
 695			elif output == 'pretty':
 696				return pretty_table(out)
 697
 698
 699def table_of_sessions(
 700	data47 = None,
 701	data48 = None,
 702	dir = 'output',
 703	filename = None,
 704	save_to_file = True,
 705	print_out = True,
 706	output = None,
 707	):
 708	'''
 709	Print out, save to disk and/or return a combined table of sessions
 710	for a pair of `D47data` and `D48data` objects.
 711	***Only applicable if the sessions in `data47` and those in `data48`
 712	consist of the exact same sets of analyses.***
 713
 714	**Parameters**
 715
 716	+ `data47`: `D47data` instance
 717	+ `data48`: `D48data` instance
 718	+ `dir`: the directory in which to save the table
 719	+ `filename`: the name to the csv file to write to
 720	+ `save_to_file`: whether to save the table to disk
 721	+ `print_out`: whether to print out the table
 722	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
 723		if set to `'raw'`: return a list of list of strings
 724		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
 725	'''
 726	if data47 is None:
 727		if data48 is None:
 728			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
 729		else:
 730			return data48.table_of_sessions(
 731				dir = dir,
 732				filename = filename,
 733				save_to_file = save_to_file,
 734				print_out = print_out,
 735				output = output
 736				)
 737	else:
 738		if data48 is None:
 739			return data47.table_of_sessions(
 740				dir = dir,
 741				filename = filename,
 742				save_to_file = save_to_file,
 743				print_out = print_out,
 744				output = output
 745				)
 746		else:
 747			out47 = data47.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
 748			out48 = data48.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
 749			for k,x in enumerate(out47[0]):
 750				if k>7:
 751					out47[0][k] = out47[0][k].replace('a', 'a_47').replace('b', 'b_47').replace('c', 'c_47')
 752					out48[0][k] = out48[0][k].replace('a', 'a_48').replace('b', 'b_48').replace('c', 'c_48')
 753			out = transpose_table(transpose_table(out47) + transpose_table(out48)[7:])
 754
 755			if save_to_file:
 756				if not os.path.exists(dir):
 757					os.makedirs(dir)
 758				if filename is None:
 759					filename = f'D47D48_sessions.csv'
 760				with open(f'{dir}/{filename}', 'w') as fid:
 761					fid.write(make_csv(out))
 762			if print_out:
 763				print('\n'+pretty_table(out))
 764			if output == 'raw':
 765				return out
 766			elif output == 'pretty':
 767				return pretty_table(out)
 768
 769
 770def table_of_analyses(
 771	data47 = None,
 772	data48 = None,
 773	dir = 'output',
 774	filename = None,
 775	save_to_file = True,
 776	print_out = True,
 777	output = None,
 778	):
 779	'''
 780	Print out, save to disk and/or return a combined table of analyses
 781	for a pair of `D47data` and `D48data` objects.
 782
 783	If the sessions in `data47` and those in `data48` do not consist of
 784	the exact same sets of analyses, the table will have two columns
 785	`Session_47` and `Session_48` instead of a single `Session` column.
 786
 787	**Parameters**
 788
 789	+ `data47`: `D47data` instance
 790	+ `data48`: `D48data` instance
 791	+ `dir`: the directory in which to save the table
 792	+ `filename`: the name to the csv file to write to
 793	+ `save_to_file`: whether to save the table to disk
 794	+ `print_out`: whether to print out the table
 795	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
 796		if set to `'raw'`: return a list of list of strings
 797		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
 798	'''
 799	if data47 is None:
 800		if data48 is None:
 801			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
 802		else:
 803			return data48.table_of_analyses(
 804				dir = dir,
 805				filename = filename,
 806				save_to_file = save_to_file,
 807				print_out = print_out,
 808				output = output
 809				)
 810	else:
 811		if data48 is None:
 812			return data47.table_of_analyses(
 813				dir = dir,
 814				filename = filename,
 815				save_to_file = save_to_file,
 816				print_out = print_out,
 817				output = output
 818				)
 819		else:
 820			out47 = data47.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
 821			out48 = data48.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
 822			
 823			if [l[1] for l in out47[1:]] == [l[1] for l in out48[1:]]: # if sessions are identical
 824				out = transpose_table(transpose_table(out47) + transpose_table(out48)[-1:])
 825			else:
 826				out47[0][1] = 'Session_47'
 827				out48[0][1] = 'Session_48'
 828				out47 = transpose_table(out47)
 829				out48 = transpose_table(out48)
 830				out = transpose_table(out47[:2] + out48[1:2] + out47[2:] + out48[-1:])
 831
 832			if save_to_file:
 833				if not os.path.exists(dir):
 834					os.makedirs(dir)
 835				if filename is None:
 836					filename = f'D47D48_sessions.csv'
 837				with open(f'{dir}/{filename}', 'w') as fid:
 838					fid.write(make_csv(out))
 839			if print_out:
 840				print('\n'+pretty_table(out))
 841			if output == 'raw':
 842				return out
 843			elif output == 'pretty':
 844				return pretty_table(out)
 845
 846
 847class D4xdata(list):
 848	'''
 849	Store and process data for a large set of Δ47 and/or Δ48
 850	analyses, usually comprising more than one analytical session.
 851	'''
 852
 853	### 17O CORRECTION PARAMETERS
 854	R13_VPDB = 0.01118  # (Chang & Li, 1990)
 855	'''
 856	Absolute (13C/12C) ratio of VPDB.
 857	By default equal to 0.01118 ([Chang & Li, 1990](http://www.cnki.com.cn/Article/CJFDTotal-JXTW199004006.htm))
 858	'''
 859
 860	R18_VSMOW = 0.0020052  # (Baertschi, 1976)
 861	'''
 862	Absolute (18O/16C) ratio of VSMOW.
 863	By default equal to 0.0020052 ([Baertschi, 1976](https://doi.org/10.1016/0012-821X(76)90115-1))
 864	'''
 865
 866	LAMBDA_17 = 0.528  # (Barkan & Luz, 2005)
 867	'''
 868	Mass-dependent exponent for triple oxygen isotopes.
 869	By default equal to 0.528 ([Barkan & Luz, 2005](https://doi.org/10.1002/rcm.2250))
 870	'''
 871
 872	R17_VSMOW = 0.00038475  # (Assonov & Brenninkmeijer, 2003, rescaled to R13_VPDB)
 873	'''
 874	Absolute (17O/16C) ratio of VSMOW.
 875	By default equal to 0.00038475
 876	([Assonov & Brenninkmeijer, 2003](https://dx.doi.org/10.1002/rcm.1011),
 877	rescaled to `R13_VPDB`)
 878	'''
 879
 880	R18_VPDB = R18_VSMOW * 1.03092
 881	'''
 882	Absolute (18O/16C) ratio of VPDB.
 883	By definition equal to `R18_VSMOW * 1.03092`.
 884	'''
 885
 886	R17_VPDB = R17_VSMOW * 1.03092 ** LAMBDA_17
 887	'''
 888	Absolute (17O/16C) ratio of VPDB.
 889	By definition equal to `R17_VSMOW * 1.03092 ** LAMBDA_17`.
 890	'''
 891
 892	LEVENE_REF_SAMPLE = 'ETH-3'
 893	'''
 894	After the Δ4x standardization step, each sample is tested to
 895	assess whether the Δ4x variance within all analyses for that
 896	sample differs significantly from that observed for a given reference
 897	sample (using [Levene's test](https://en.wikipedia.org/wiki/Levene%27s_test),
 898	which yields a p-value corresponding to the null hypothesis that the
 899	underlying variances are equal).
 900
 901	`LEVENE_REF_SAMPLE` (by default equal to `'ETH-3'`) specifies which
 902	sample should be used as a reference for this test.
 903	'''
 904
 905	ALPHA_18O_ACID_REACTION = round(np.exp(3.59 / (90 + 273.15) - 1.79e-3), 6)  # (Kim et al., 2007, calcite)
 906	'''
 907	Specifies the 18O/16O fractionation factor generally applicable
 908	to acid reactions in the dataset. Currently used by `D4xdata.wg()`,
 909	`D4xdata.standardize_d13C`, and `D4xdata.standardize_d18O`.
 910
 911	By default equal to 1.008129 (calcite reacted at 90 °C,
 912	[Kim et al., 2007](https://dx.doi.org/10.1016/j.chemgeo.2007.08.005)).
 913	'''
 914
 915	Nominal_d13C_VPDB = {
 916		'ETH-1': 2.02,
 917		'ETH-2': -10.17,
 918		'ETH-3': 1.71,
 919		}	# (Bernasconi et al., 2018)
 920	'''
 921	Nominal δ13C_VPDB values assigned to carbonate standards, used by
 922	`D4xdata.standardize_d13C()`.
 923
 924	By default equal to `{'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71}` after
 925	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
 926	'''
 927
 928	Nominal_d18O_VPDB = {
 929		'ETH-1': -2.19,
 930		'ETH-2': -18.69,
 931		'ETH-3': -1.78,
 932		}	# (Bernasconi et al., 2018)
 933	'''
 934	Nominal δ18O_VPDB values assigned to carbonate standards, used by
 935	`D4xdata.standardize_d18O()`.
 936
 937	By default equal to `{'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78}` after
 938	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
 939	'''
 940
 941	d13C_STANDARDIZATION_METHOD = '2pt'
 942	'''
 943	Method by which to standardize δ13C values:
 944	
 945	+ `none`: do not apply any δ13C standardization.
 946	+ `'1pt'`: within each session, offset all initial δ13C values so as to
 947	minimize the difference between final δ13C_VPDB values and
 948	`Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB` is defined).
 949	+ `'2pt'`: within each session, apply a affine trasformation to all δ13C
 950	values so as to minimize the difference between final δ13C_VPDB
 951	values and `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB`
 952	is defined).
 953	'''
 954
 955	d18O_STANDARDIZATION_METHOD = '2pt'
 956	'''
 957	Method by which to standardize δ18O values:
 958	
 959	+ `none`: do not apply any δ18O standardization.
 960	+ `'1pt'`: within each session, offset all initial δ18O values so as to
 961	minimize the difference between final δ18O_VPDB values and
 962	`Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` is defined).
 963	+ `'2pt'`: within each session, apply a affine trasformation to all δ18O
 964	values so as to minimize the difference between final δ18O_VPDB
 965	values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB`
 966	is defined).
 967	'''
 968
 969	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
 970		'''
 971		**Parameters**
 972
 973		+ `l`: a list of dictionaries, with each dictionary including at least the keys
 974		`Sample`, `d45`, `d46`, and `d47` or `d48`.
 975		+ `mass`: `'47'` or `'48'`
 976		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
 977		+ `session`: define session name for analyses without a `Session` key
 978		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.
 979
 980		Returns a `D4xdata` object derived from `list`.
 981		'''
 982		self._4x = mass
 983		self.verbose = verbose
 984		self.prefix = 'D4xdata'
 985		self.logfile = logfile
 986		list.__init__(self, l)
 987		self.Nf = None
 988		self.repeatability = {}
 989		self.refresh(session = session)
 990
 991
 992	def make_verbal(oldfun):
 993		'''
 994		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
 995		'''
 996		@wraps(oldfun)
 997		def newfun(*args, verbose = '', **kwargs):
 998			myself = args[0]
 999			oldprefix = myself.prefix
1000			myself.prefix = oldfun.__name__
1001			if verbose != '':
1002				oldverbose = myself.verbose
1003				myself.verbose = verbose
1004			out = oldfun(*args, **kwargs)
1005			myself.prefix = oldprefix
1006			if verbose != '':
1007				myself.verbose = oldverbose
1008			return out
1009		return newfun
1010
1011
1012	def msg(self, txt):
1013		'''
1014		Log a message to `self.logfile`, and print it out if `verbose = True`
1015		'''
1016		self.log(txt)
1017		if self.verbose:
1018			print(f'{f"[{self.prefix}]":<16} {txt}')
1019
1020
1021	def vmsg(self, txt):
1022		'''
1023		Log a message to `self.logfile` and print it out
1024		'''
1025		self.log(txt)
1026		print(txt)
1027
1028
1029	def log(self, *txts):
1030		'''
1031		Log a message to `self.logfile`
1032		'''
1033		if self.logfile:
1034			with open(self.logfile, 'a') as fid:
1035				for txt in txts:
1036					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
1037
1038
1039	def refresh(self, session = 'mySession'):
1040		'''
1041		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
1042		'''
1043		self.fill_in_missing_info(session = session)
1044		self.refresh_sessions()
1045		self.refresh_samples()
1046
1047
1048	def refresh_sessions(self):
1049		'''
1050		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
1051		to `False` for all sessions.
1052		'''
1053		self.sessions = {
1054			s: {'data': [r for r in self if r['Session'] == s]}
1055			for s in sorted({r['Session'] for r in self})
1056			}
1057		for s in self.sessions:
1058			self.sessions[s]['scrambling_drift'] = False
1059			self.sessions[s]['slope_drift'] = False
1060			self.sessions[s]['wg_drift'] = False
1061			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
1062			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
1063
1064
1065	def refresh_samples(self):
1066		'''
1067		Define `self.samples`, `self.anchors`, and `self.unknowns`.
1068		'''
1069		self.samples = {
1070			s: {'data': [r for r in self if r['Sample'] == s]}
1071			for s in sorted({r['Sample'] for r in self})
1072			}
1073		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
1074		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
1075
1076
1077	def read(self, filename, sep = '', session = ''):
1078		'''
1079		Read file in csv format to load data into a `D47data` object.
1080
1081		In the csv file, spaces before and after field separators (`','` by default)
1082		are optional. Each line corresponds to a single analysis.
1083
1084		The required fields are:
1085
1086		+ `UID`: a unique identifier
1087		+ `Session`: an identifier for the analytical session
1088		+ `Sample`: a sample identifier
1089		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
1090
1091		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
1092		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
1093		and `d49` are optional, and set to NaN by default.
1094
1095		**Parameters**
1096
1097		+ `fileneme`: the path of the file to read
1098		+ `sep`: csv separator delimiting the fields
1099		+ `session`: set `Session` field to this string for all analyses
1100		'''
1101		with open(filename) as fid:
1102			self.input(fid.read(), sep = sep, session = session)
1103
1104
1105	def input(self, txt, sep = '', session = ''):
1106		'''
1107		Read `txt` string in csv format to load analysis data into a `D47data` object.
1108
1109		In the csv string, spaces before and after field separators (`','` by default)
1110		are optional. Each line corresponds to a single analysis.
1111
1112		The required fields are:
1113
1114		+ `UID`: a unique identifier
1115		+ `Session`: an identifier for the analytical session
1116		+ `Sample`: a sample identifier
1117		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
1118
1119		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
1120		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
1121		and `d49` are optional, and set to NaN by default.
1122
1123		**Parameters**
1124
1125		+ `txt`: the csv string to read
1126		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
1127		whichever appers most often in `txt`.
1128		+ `session`: set `Session` field to this string for all analyses
1129		'''
1130		if sep == '':
1131			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
1132		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
1133		data = [{k: v if k in ['UID', 'Session', 'Sample'] else smart_type(v) for k,v in zip(txt[0], l) if v != ''} for l in txt[1:]]
1134
1135		if session != '':
1136			for r in data:
1137				r['Session'] = session
1138
1139		self += data
1140		self.refresh()
1141
1142
1143	@make_verbal
1144	def wg(self, samples = None, a18_acid = None):
1145		'''
1146		Compute bulk composition of the working gas for each session based on
1147		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
1148		`self.Nominal_d18O_VPDB`.
1149		'''
1150
1151		self.msg('Computing WG composition:')
1152
1153		if a18_acid is None:
1154			a18_acid = self.ALPHA_18O_ACID_REACTION
1155		if samples is None:
1156			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
1157
1158		assert a18_acid, f'Acid fractionation factor should not be zero.'
1159
1160		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
1161		R45R46_standards = {}
1162		for sample in samples:
1163			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
1164			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
1165			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
1166			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
1167			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
1168
1169			C12_s = 1 / (1 + R13_s)
1170			C13_s = R13_s / (1 + R13_s)
1171			C16_s = 1 / (1 + R17_s + R18_s)
1172			C17_s = R17_s / (1 + R17_s + R18_s)
1173			C18_s = R18_s / (1 + R17_s + R18_s)
1174
1175			C626_s = C12_s * C16_s ** 2
1176			C627_s = 2 * C12_s * C16_s * C17_s
1177			C628_s = 2 * C12_s * C16_s * C18_s
1178			C636_s = C13_s * C16_s ** 2
1179			C637_s = 2 * C13_s * C16_s * C17_s
1180			C727_s = C12_s * C17_s ** 2
1181
1182			R45_s = (C627_s + C636_s) / C626_s
1183			R46_s = (C628_s + C637_s + C727_s) / C626_s
1184			R45R46_standards[sample] = (R45_s, R46_s)
1185		
1186		for s in self.sessions:
1187			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
1188			assert db, f'No sample from {samples} found in session "{s}".'
1189# 			dbsamples = sorted({r['Sample'] for r in db})
1190
1191			X = [r['d45'] for r in db]
1192			Y = [R45R46_standards[r['Sample']][0] for r in db]
1193			x1, x2 = np.min(X), np.max(X)
1194
1195			if x1 < x2:
1196				wgcoord = x1/(x1-x2)
1197			else:
1198				wgcoord = 999
1199
1200			if wgcoord < -.5 or wgcoord > 1.5:
1201				# unreasonable to extrapolate to d45 = 0
1202				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
1203			else :
1204				# d45 = 0 is reasonably well bracketed
1205				R45_wg = np.polyfit(X, Y, 1)[1]
1206
1207			X = [r['d46'] for r in db]
1208			Y = [R45R46_standards[r['Sample']][1] for r in db]
1209			x1, x2 = np.min(X), np.max(X)
1210
1211			if x1 < x2:
1212				wgcoord = x1/(x1-x2)
1213			else:
1214				wgcoord = 999
1215
1216			if wgcoord < -.5 or wgcoord > 1.5:
1217				# unreasonable to extrapolate to d46 = 0
1218				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
1219			else :
1220				# d46 = 0 is reasonably well bracketed
1221				R46_wg = np.polyfit(X, Y, 1)[1]
1222
1223			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
1224
1225			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
1226
1227			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
1228			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
1229			for r in self.sessions[s]['data']:
1230				r['d13Cwg_VPDB'] = d13Cwg_VPDB
1231				r['d18Owg_VSMOW'] = d18Owg_VSMOW
1232
1233
1234	def compute_bulk_delta(self, R45, R46, D17O = 0):
1235		'''
1236		Compute δ13C_VPDB and δ18O_VSMOW,
1237		by solving the generalized form of equation (17) from
1238		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
1239		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
1240		solving the corresponding second-order Taylor polynomial.
1241		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
1242		'''
1243
1244		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
1245
1246		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
1247		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
1248		C = 2 * self.R18_VSMOW
1249		D = -R46
1250
1251		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
1252		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
1253		cc = A + B + C + D
1254
1255		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
1256
1257		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
1258		R17 = K * R18 ** self.LAMBDA_17
1259		R13 = R45 - 2 * R17
1260
1261		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
1262
1263		return d13C_VPDB, d18O_VSMOW
1264
1265
1266	@make_verbal
1267	def crunch(self, verbose = ''):
1268		'''
1269		Compute bulk composition and raw clumped isotope anomalies for all analyses.
1270		'''
1271		for r in self:
1272			self.compute_bulk_and_clumping_deltas(r)
1273		self.standardize_d13C()
1274		self.standardize_d18O()
1275		self.msg(f"Crunched {len(self)} analyses.")
1276
1277
1278	def fill_in_missing_info(self, session = 'mySession'):
1279		'''
1280		Fill in optional fields with default values
1281		'''
1282		for i,r in enumerate(self):
1283			if 'D17O' not in r:
1284				r['D17O'] = 0.
1285			if 'UID' not in r:
1286				r['UID'] = f'{i+1}'
1287			if 'Session' not in r:
1288				r['Session'] = session
1289			for k in ['d47', 'd48', 'd49']:
1290				if k not in r:
1291					r[k] = np.nan
1292
1293
1294	def standardize_d13C(self):
1295		'''
1296		Perform δ13C standadization within each session `s` according to
1297		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
1298		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
1299		may be redefined abitrarily at a later stage.
1300		'''
1301		for s in self.sessions:
1302			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
1303				XY = [(r['d13C_VPDB'], self.Nominal_d13C_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d13C_VPDB]
1304				X,Y = zip(*XY)
1305				if self.sessions[s]['d13C_standardization_method'] == '1pt':
1306					offset = np.mean(Y) - np.mean(X)
1307					for r in self.sessions[s]['data']:
1308						r['d13C_VPDB'] += offset				
1309				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
1310					a,b = np.polyfit(X,Y,1)
1311					for r in self.sessions[s]['data']:
1312						r['d13C_VPDB'] = a * r['d13C_VPDB'] + b
1313
1314	def standardize_d18O(self):
1315		'''
1316		Perform δ18O standadization within each session `s` according to
1317		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
1318		which is defined by default by `D47data.refresh_sessions()`as equal to
1319		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
1320		'''
1321		for s in self.sessions:
1322			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
1323				XY = [(r['d18O_VSMOW'], self.Nominal_d18O_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d18O_VPDB]
1324				X,Y = zip(*XY)
1325				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
1326				if self.sessions[s]['d18O_standardization_method'] == '1pt':
1327					offset = np.mean(Y) - np.mean(X)
1328					for r in self.sessions[s]['data']:
1329						r['d18O_VSMOW'] += offset				
1330				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
1331					a,b = np.polyfit(X,Y,1)
1332					for r in self.sessions[s]['data']:
1333						r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b
1334	
1335
1336	def compute_bulk_and_clumping_deltas(self, r):
1337		'''
1338		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
1339		'''
1340
1341		# Compute working gas R13, R18, and isobar ratios
1342		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
1343		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
1344		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
1345
1346		# Compute analyte isobar ratios
1347		R45 = (1 + r['d45'] / 1000) * R45_wg
1348		R46 = (1 + r['d46'] / 1000) * R46_wg
1349		R47 = (1 + r['d47'] / 1000) * R47_wg
1350		R48 = (1 + r['d48'] / 1000) * R48_wg
1351		R49 = (1 + r['d49'] / 1000) * R49_wg
1352
1353		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
1354		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
1355		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
1356
1357		# Compute stochastic isobar ratios of the analyte
1358		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
1359			R13, R18, D17O = r['D17O']
1360		)
1361
1362		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
1363		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
1364		if (R45 / R45stoch - 1) > 5e-8:
1365			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
1366		if (R46 / R46stoch - 1) > 5e-8:
1367			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
1368
1369		# Compute raw clumped isotope anomalies
1370		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
1371		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
1372		r['D49raw'] = 1000 * (R49 / R49stoch - 1)
1373
1374
1375	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
1376		'''
1377		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
1378		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
1379		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
1380		'''
1381
1382		# Compute R17
1383		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
1384
1385		# Compute isotope concentrations
1386		C12 = (1 + R13) ** -1
1387		C13 = C12 * R13
1388		C16 = (1 + R17 + R18) ** -1
1389		C17 = C16 * R17
1390		C18 = C16 * R18
1391
1392		# Compute stochastic isotopologue concentrations
1393		C626 = C16 * C12 * C16
1394		C627 = C16 * C12 * C17 * 2
1395		C628 = C16 * C12 * C18 * 2
1396		C636 = C16 * C13 * C16
1397		C637 = C16 * C13 * C17 * 2
1398		C638 = C16 * C13 * C18 * 2
1399		C727 = C17 * C12 * C17
1400		C728 = C17 * C12 * C18 * 2
1401		C737 = C17 * C13 * C17
1402		C738 = C17 * C13 * C18 * 2
1403		C828 = C18 * C12 * C18
1404		C838 = C18 * C13 * C18
1405
1406		# Compute stochastic isobar ratios
1407		R45 = (C636 + C627) / C626
1408		R46 = (C628 + C637 + C727) / C626
1409		R47 = (C638 + C728 + C737) / C626
1410		R48 = (C738 + C828) / C626
1411		R49 = C838 / C626
1412
1413		# Account for stochastic anomalies
1414		R47 *= 1 + D47 / 1000
1415		R48 *= 1 + D48 / 1000
1416		R49 *= 1 + D49 / 1000
1417
1418		# Return isobar ratios
1419		return R45, R46, R47, R48, R49
1420
1421
1422	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
1423		'''
1424		Split unknown samples by UID (treat all analyses as different samples)
1425		or by session (treat analyses of a given sample in different sessions as
1426		different samples).
1427
1428		**Parameters**
1429
1430		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
1431		+ `grouping`: `by_uid` | `by_session`
1432		'''
1433		if samples_to_split == 'all':
1434			samples_to_split = [s for s in self.unknowns]
1435		gkeys = {'by_uid':'UID', 'by_session':'Session'}
1436		self.grouping = grouping.lower()
1437		if self.grouping in gkeys:
1438			gkey = gkeys[self.grouping]
1439		for r in self:
1440			if r['Sample'] in samples_to_split:
1441				r['Sample_original'] = r['Sample']
1442				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
1443			elif r['Sample'] in self.unknowns:
1444				r['Sample_original'] = r['Sample']
1445		self.refresh_samples()
1446
1447
1448	def unsplit_samples(self, tables = False):
1449		'''
1450		Reverse the effects of `D47data.split_samples()`.
1451		
1452		This should only be used after `D4xdata.standardize()` with `method='pooled'`.
1453		
1454		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
1455		probably use `D4xdata.combine_samples()` instead to reverse the effects of
1456		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
1457		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
1458		that case session-averaged Δ4x values are statistically independent).
1459		'''
1460		unknowns_old = sorted({s for s in self.unknowns})
1461		CM_old = self.standardization.covar[:,:]
1462		VD_old = self.standardization.params.valuesdict().copy()
1463		vars_old = self.standardization.var_names
1464
1465		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
1466
1467		Ns = len(vars_old) - len(unknowns_old)
1468		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
1469		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
1470
1471		W = np.zeros((len(vars_new), len(vars_old)))
1472		W[:Ns,:Ns] = np.eye(Ns)
1473		for u in unknowns_new:
1474			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
1475			if self.grouping == 'by_session':
1476				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
1477			elif self.grouping == 'by_uid':
1478				weights = [1 for s in splits]
1479			sw = sum(weights)
1480			weights = [w/sw for w in weights]
1481			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
1482
1483		CM_new = W @ CM_old @ W.T
1484		V = W @ np.array([[VD_old[k]] for k in vars_old])
1485		VD_new = {k:v[0] for k,v in zip(vars_new, V)}
1486
1487		self.standardization.covar = CM_new
1488		self.standardization.params.valuesdict = lambda : VD_new
1489		self.standardization.var_names = vars_new
1490
1491		for r in self:
1492			if r['Sample'] in self.unknowns:
1493				r['Sample_split'] = r['Sample']
1494				r['Sample'] = r['Sample_original']
1495
1496		self.refresh_samples()
1497		self.consolidate_samples()
1498		self.repeatabilities()
1499
1500		if tables:
1501			self.table_of_analyses()
1502			self.table_of_samples()
1503
1504	def assign_timestamps(self):
1505		'''
1506		Assign a time field `t` of type `float` to each analysis.
1507
1508		If `TimeTag` is one of the data fields, `t` is equal within a given session
1509		to `TimeTag` minus the mean value of `TimeTag` for that session.
1510		Otherwise, `TimeTag` is by default equal to the index of each analysis
1511		in the dataset and `t` is defined as above.
1512		'''
1513		for session in self.sessions:
1514			sdata = self.sessions[session]['data']
1515			try:
1516				t0 = np.mean([r['TimeTag'] for r in sdata])
1517				for r in sdata:
1518					r['t'] = r['TimeTag'] - t0
1519			except KeyError:
1520				t0 = (len(sdata)-1)/2
1521				for t,r in enumerate(sdata):
1522					r['t'] = t - t0
1523
1524
1525	def report(self):
1526		'''
1527		Prints a report on the standardization fit.
1528		Only applicable after `D4xdata.standardize(method='pooled')`.
1529		'''
1530		report_fit(self.standardization)
1531
1532
1533	def combine_samples(self, sample_groups):
1534		'''
1535		Combine analyses of different samples to compute weighted average Δ4x
1536		and new error (co)variances corresponding to the groups defined by the `sample_groups`
1537		dictionary.
1538		
1539		Caution: samples are weighted by number of replicate analyses, which is a
1540		reasonable default behavior but is not always optimal (e.g., in the case of strongly
1541		correlated analytical errors for one or more samples).
1542		
1543		Returns a tuplet of:
1544		
1545		+ the list of group names
1546		+ an array of the corresponding Δ4x values
1547		+ the corresponding (co)variance matrix
1548		
1549		**Parameters**
1550
1551		+ `sample_groups`: a dictionary of the form:
1552		```py
1553		{'group1': ['sample_1', 'sample_2'],
1554		 'group2': ['sample_3', 'sample_4', 'sample_5']}
1555		```
1556		'''
1557		
1558		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
1559		groups = sorted(sample_groups.keys())
1560		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
1561		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
1562		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
1563		W = np.array([
1564			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
1565			for j in groups])
1566		D4x_new = W @ D4x_old
1567		CM_new = W @ CM_old @ W.T
1568
1569		return groups, D4x_new[:,0], CM_new
1570		
1571
1572	@make_verbal
1573	def standardize(self,
1574		method = 'pooled',
1575		weighted_sessions = [],
1576		consolidate = True,
1577		consolidate_tables = False,
1578		consolidate_plots = False,
1579		constraints = {},
1580		):
1581		'''
1582		Compute absolute Δ4x values for all replicate analyses and for sample averages.
1583		If `method` argument is set to `'pooled'`, the standardization processes all sessions
1584		in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
1585		i.e. that their true Δ4x value does not change between sessions,
1586		([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
1587		`'indep_sessions'`, the standardization processes each session independently, based only
1588		on anchors analyses.
1589		'''
1590
1591		self.standardization_method = method
1592		self.assign_timestamps()
1593
1594		if method == 'pooled':
1595			if weighted_sessions:
1596				for session_group in weighted_sessions:
1597					if self._4x == '47':
1598						X = D47data([r for r in self if r['Session'] in session_group])
1599					elif self._4x == '48':
1600						X = D48data([r for r in self if r['Session'] in session_group])
1601					X.Nominal_D4x = self.Nominal_D4x.copy()
1602					X.refresh()
1603					result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
1604					w = np.sqrt(result.redchi)
1605					self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
1606					for r in X:
1607						r[f'wD{self._4x}raw'] *= w
1608			else:
1609				self.msg(f'All D{self._4x}raw weights set to 1 ‰')
1610				for r in self:
1611					r[f'wD{self._4x}raw'] = 1.
1612
1613			params = Parameters()
1614			for k,session in enumerate(self.sessions):
1615				self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
1616				self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
1617				self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
1618				s = pf(session)
1619				params.add(f'a_{s}', value = 0.9)
1620				params.add(f'b_{s}', value = 0.)
1621				params.add(f'c_{s}', value = -0.9)
1622				params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift'])
1623				params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift'])
1624				params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift'])
1625			for sample in self.unknowns:
1626				params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)
1627
1628			for k in constraints:
1629				params[k].expr = constraints[k]
1630
1631			def residuals(p):
1632				R = []
1633				for r in self:
1634					session = pf(r['Session'])
1635					sample = pf(r['Sample'])
1636					if r['Sample'] in self.Nominal_D4x:
1637						R += [ (
1638							r[f'D{self._4x}raw'] - (
1639								p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
1640								+ p[f'b_{session}'] * r[f'd{self._4x}']
1641								+	p[f'c_{session}']
1642								+ r['t'] * (
1643									p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
1644									+ p[f'b2_{session}'] * r[f'd{self._4x}']
1645									+	p[f'c2_{session}']
1646									)
1647								)
1648							) / r[f'wD{self._4x}raw'] ]
1649					else:
1650						R += [ (
1651							r[f'D{self._4x}raw'] - (
1652								p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
1653								+ p[f'b_{session}'] * r[f'd{self._4x}']
1654								+	p[f'c_{session}']
1655								+ r['t'] * (
1656									p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
1657									+ p[f'b2_{session}'] * r[f'd{self._4x}']
1658									+	p[f'c2_{session}']
1659									)
1660								)
1661							) / r[f'wD{self._4x}raw'] ]
1662				return R
1663
1664			M = Minimizer(residuals, params)
1665			result = M.least_squares()
1666			self.Nf = result.nfree
1667			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
1668# 			if self.verbose:
1669# 				report_fit(result)
1670
1671			for r in self:
1672				s = pf(r["Session"])
1673				a = result.params.valuesdict()[f'a_{s}']
1674				b = result.params.valuesdict()[f'b_{s}']
1675				c = result.params.valuesdict()[f'c_{s}']
1676				a2 = result.params.valuesdict()[f'a2_{s}']
1677				b2 = result.params.valuesdict()[f'b2_{s}']
1678				c2 = result.params.valuesdict()[f'c2_{s}']
1679				r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
1680
1681			self.standardization = result
1682
1683			for session in self.sessions:
1684				self.sessions[session]['Np'] = 3
1685				for k in ['scrambling', 'slope', 'wg']:
1686					if self.sessions[session][f'{k}_drift']:
1687						self.sessions[session]['Np'] += 1
1688
1689			if consolidate:
1690				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
1691			return result
1692
1693
1694		elif method == 'indep_sessions':
1695
1696			if weighted_sessions:
1697				for session_group in weighted_sessions:
1698					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
1699					X.Nominal_D4x = self.Nominal_D4x.copy()
1700					X.refresh()
1701					# This is only done to assign r['wD47raw'] for r in X:
1702					X.standardize(method = method, weighted_sessions = [], consolidate = False)
1703					self.msg(f'D{self._4x}raw weights set to {1000*X[0][f"wD{self._4x}raw"]:.1f} ppm for sessions in {session_group}')
1704			else:
1705				self.msg('All weights set to 1 ‰')
1706				for r in self:
1707					r[f'wD{self._4x}raw'] = 1
1708
1709			for session in self.sessions:
1710				s = self.sessions[session]
1711				p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
1712				p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
1713				s['Np'] = sum(p_active)
1714				sdata = s['data']
1715
1716				A = np.array([
1717					[
1718						self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
1719						r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
1720						1 / r[f'wD{self._4x}raw'],
1721						self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
1722						r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
1723						r['t'] / r[f'wD{self._4x}raw']
1724						]
1725					for r in sdata if r['Sample'] in self.anchors
1726					])[:,p_active] # only keep columns for the active parameters
1727				Y = np.array([[r[f'D{self._4x}raw'] / r[f'wD{self._4x}raw']] for r in sdata if r['Sample'] in self.anchors])
1728				s['Na'] = Y.size
1729				CM = linalg.inv(A.T @ A)
1730				bf = (CM @ A.T @ Y).T[0,:]
1731				k = 0
1732				for n,a in zip(p_names, p_active):
1733					if a:
1734						s[n] = bf[k]
1735# 						self.msg(f'{n} = {bf[k]}')
1736						k += 1
1737					else:
1738						s[n] = 0.
1739# 						self.msg(f'{n} = 0.0')
1740
1741				for r in sdata :
1742					a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
1743					r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
1744					r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])
1745
1746				s['CM'] = np.zeros((6,6))
1747				i = 0
1748				k_active = [j for j,a in enumerate(p_active) if a]
1749				for j,a in enumerate(p_active):
1750					if a:
1751						s['CM'][j,k_active] = CM[i,:]
1752						i += 1
1753
1754			if not weighted_sessions:
1755				w = self.rmswd()['rmswd']
1756				for r in self:
1757						r[f'wD{self._4x}'] *= w
1758						r[f'wD{self._4x}raw'] *= w
1759				for session in self.sessions:
1760					self.sessions[session]['CM'] *= w**2
1761
1762			for session in self.sessions:
1763				s = self.sessions[session]
1764				s['SE_a'] = s['CM'][0,0]**.5
1765				s['SE_b'] = s['CM'][1,1]**.5
1766				s['SE_c'] = s['CM'][2,2]**.5
1767				s['SE_a2'] = s['CM'][3,3]**.5
1768				s['SE_b2'] = s['CM'][4,4]**.5
1769				s['SE_c2'] = s['CM'][5,5]**.5
1770
1771			if not weighted_sessions:
1772				self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
1773			else:
1774				self.Nf = 0
1775				for sg in weighted_sessions:
1776					self.Nf += self.rmswd(sessions = sg)['Nf']
1777
1778			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
1779
1780			avgD4x = {
1781				sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
1782				for sample in self.samples
1783				}
1784			chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
1785			rD4x = (chi2/self.Nf)**.5
1786			self.repeatability[f'sigma_{self._4x}'] = rD4x
1787
1788			if consolidate:
1789				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
1790
1791
1792	def standardization_error(self, session, d4x, D4x, t = 0):
1793		'''
1794		Compute standardization error for a given session and
1795		(δ47, Δ47) composition.
1796		'''
1797		a = self.sessions[session]['a']
1798		b = self.sessions[session]['b']
1799		c = self.sessions[session]['c']
1800		a2 = self.sessions[session]['a2']
1801		b2 = self.sessions[session]['b2']
1802		c2 = self.sessions[session]['c2']
1803		CM = self.sessions[session]['CM']
1804
1805		x, y = D4x, d4x
1806		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
1807# 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
1808		dxdy = -(b+b2*t) / (a+a2*t)
1809		dxdz = 1. / (a+a2*t)
1810		dxda = -x / (a+a2*t)
1811		dxdb = -y / (a+a2*t)
1812		dxdc = -1. / (a+a2*t)
1813		dxda2 = -x * a2 / (a+a2*t)
1814		dxdb2 = -y * t / (a+a2*t)
1815		dxdc2 = -t / (a+a2*t)
1816		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
1817		sx = (V @ CM @ V.T) ** .5
1818		return sx
1819
1820
1821	@make_verbal
1822	def summary(self,
1823		dir = 'output',
1824		filename = None,
1825		save_to_file = True,
1826		print_out = True,
1827		):
1828		'''
1829		Print out an/or save to disk a summary of the standardization results.
1830
1831		**Parameters**
1832
1833		+ `dir`: the directory in which to save the table
1834		+ `filename`: the name to the csv file to write to
1835		+ `save_to_file`: whether to save the table to disk
1836		+ `print_out`: whether to print out the table
1837		'''
1838
1839		out = []
1840		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
1841		out += [['N analyses (anchors + unknowns)', f"{len(self)} ({len([r for r in self if r['Sample'] in self.anchors])} + {len([r for r in self if r['Sample'] in self.unknowns])})"]]
1842		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
1843		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
1844		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
1845		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
1846		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
1847		out += [['Model degrees of freedom', f"{self.Nf}"]]
1848		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
1849		out += [['Standardization method', self.standardization_method]]
1850
1851		if save_to_file:
1852			if not os.path.exists(dir):
1853				os.makedirs(dir)
1854			if filename is None:
1855				filename = f'D{self._4x}_summary.csv'
1856			with open(f'{dir}/{filename}', 'w') as fid:
1857				fid.write(make_csv(out))
1858		if print_out:
1859			self.msg('\n' + pretty_table(out, header = 0))
1860
1861
1862	@make_verbal
1863	def table_of_sessions(self,
1864		dir = 'output',
1865		filename = None,
1866		save_to_file = True,
1867		print_out = True,
1868		output = None,
1869		):
1870		'''
1871		Print out an/or save to disk a table of sessions.
1872
1873		**Parameters**
1874
1875		+ `dir`: the directory in which to save the table
1876		+ `filename`: the name to the csv file to write to
1877		+ `save_to_file`: whether to save the table to disk
1878		+ `print_out`: whether to print out the table
1879		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
1880		    if set to `'raw'`: return a list of list of strings
1881		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
1882		'''
1883		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
1884		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
1885		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
1886
1887		out = [['Session','Na','Nu','d13Cwg_VPDB','d18Owg_VSMOW','r_d13C','r_d18O',f'r_D{self._4x}','a ± SE','1e3 x b ± SE','c ± SE']]
1888		if include_a2:
1889			out[-1] += ['a2 ± SE']
1890		if include_b2:
1891			out[-1] += ['b2 ± SE']
1892		if include_c2:
1893			out[-1] += ['c2 ± SE']
1894		for session in self.sessions:
1895			out += [[
1896				session,
1897				f"{self.sessions[session]['Na']}",
1898				f"{self.sessions[session]['Nu']}",
1899				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
1900				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
1901				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
1902				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
1903				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
1904				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
1905				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
1906				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
1907				]]
1908			if include_a2:
1909				if self.sessions[session]['scrambling_drift']:
1910					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
1911				else:
1912					out[-1] += ['']
1913			if include_b2:
1914				if self.sessions[session]['slope_drift']:
1915					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
1916				else:
1917					out[-1] += ['']
1918			if include_c2:
1919				if self.sessions[session]['wg_drift']:
1920					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
1921				else:
1922					out[-1] += ['']
1923
1924		if save_to_file:
1925			if not os.path.exists(dir):
1926				os.makedirs(dir)
1927			if filename is None:
1928				filename = f'D{self._4x}_sessions.csv'
1929			with open(f'{dir}/{filename}', 'w') as fid:
1930				fid.write(make_csv(out))
1931		if print_out:
1932			self.msg('\n' + pretty_table(out))
1933		if output == 'raw':
1934			return out
1935		elif output == 'pretty':
1936			return pretty_table(out)
1937
1938
1939	@make_verbal
1940	def table_of_analyses(
1941		self,
1942		dir = 'output',
1943		filename = None,
1944		save_to_file = True,
1945		print_out = True,
1946		output = None,
1947		):
1948		'''
1949		Print out an/or save to disk a table of analyses.
1950
1951		**Parameters**
1952
1953		+ `dir`: the directory in which to save the table
1954		+ `filename`: the name to the csv file to write to
1955		+ `save_to_file`: whether to save the table to disk
1956		+ `print_out`: whether to print out the table
1957		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
1958		    if set to `'raw'`: return a list of list of strings
1959		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
1960		'''
1961
1962		out = [['UID','Session','Sample']]
1963		extra_fields = [f for f in [('SampleMass','.2f'),('ColdFingerPressure','.1f'),('AcidReactionYield','.3f')] if f[0] in {k for r in self for k in r}]
1964		for f in extra_fields:
1965			out[-1] += [f[0]]
1966		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
1967		for r in self:
1968			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
1969			for f in extra_fields:
1970				out[-1] += [f"{r[f[0]]:{f[1]}}"]
1971			out[-1] += [
1972				f"{r['d13Cwg_VPDB']:.3f}",
1973				f"{r['d18Owg_VSMOW']:.3f}",
1974				f"{r['d45']:.6f}",
1975				f"{r['d46']:.6f}",
1976				f"{r['d47']:.6f}",
1977				f"{r['d48']:.6f}",
1978				f"{r['d49']:.6f}",
1979				f"{r['d13C_VPDB']:.6f}",
1980				f"{r['d18O_VSMOW']:.6f}",
1981				f"{r['D47raw']:.6f}",
1982				f"{r['D48raw']:.6f}",
1983				f"{r['D49raw']:.6f}",
1984				f"{r[f'D{self._4x}']:.6f}"
1985				]
1986		if save_to_file:
1987			if not os.path.exists(dir):
1988				os.makedirs(dir)
1989			if filename is None:
1990				filename = f'D{self._4x}_analyses.csv'
1991			with open(f'{dir}/{filename}', 'w') as fid:
1992				fid.write(make_csv(out))
1993		if print_out:
1994			self.msg('\n' + pretty_table(out))
1995		return out
1996
1997	@make_verbal
1998	def covar_table(
1999		self,
2000		correl = False,
2001		dir = 'output',
2002		filename = None,
2003		save_to_file = True,
2004		print_out = True,
2005		output = None,
2006		):
2007		'''
2008		Print out, save to disk and/or return the variance-covariance matrix of D4x
2009		for all unknown samples.
2010
2011		**Parameters**
2012
2013		+ `dir`: the directory in which to save the csv
2014		+ `filename`: the name of the csv file to write to
2015		+ `save_to_file`: whether to save the csv
2016		+ `print_out`: whether to print out the matrix
2017		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
2018		    if set to `'raw'`: return a list of list of strings
2019		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
2020		'''
2021		samples = sorted([u for u in self.unknowns])
2022		out = [[''] + samples]
2023		for s1 in samples:
2024			out.append([s1])
2025			for s2 in samples:
2026				if correl:
2027					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
2028				else:
2029					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
2030
2031		if save_to_file:
2032			if not os.path.exists(dir):
2033				os.makedirs(dir)
2034			if filename is None:
2035				if correl:
2036					filename = f'D{self._4x}_correl.csv'
2037				else:
2038					filename = f'D{self._4x}_covar.csv'
2039			with open(f'{dir}/{filename}', 'w') as fid:
2040				fid.write(make_csv(out))
2041		if print_out:
2042			self.msg('\n'+pretty_table(out))
2043		if output == 'raw':
2044			return out
2045		elif output == 'pretty':
2046			return pretty_table(out)
2047
2048	@make_verbal
2049	def table_of_samples(
2050		self,
2051		dir = 'output',
2052		filename = None,
2053		save_to_file = True,
2054		print_out = True,
2055		output = None,
2056		):
2057		'''
2058		Print out, save to disk and/or return a table of samples.
2059
2060		**Parameters**
2061
2062		+ `dir`: the directory in which to save the csv
2063		+ `filename`: the name of the csv file to write to
2064		+ `save_to_file`: whether to save the csv
2065		+ `print_out`: whether to print out the table
2066		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
2067		    if set to `'raw'`: return a list of list of strings
2068		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
2069		'''
2070
2071		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
2072		for sample in self.anchors:
2073			out += [[
2074				f"{sample}",
2075				f"{self.samples[sample]['N']}",
2076				f"{self.samples[sample]['d13C_VPDB']:.2f}",
2077				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
2078				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
2079				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
2080				]]
2081		for sample in self.unknowns:
2082			out += [[
2083				f"{sample}",
2084				f"{self.samples[sample]['N']}",
2085				f"{self.samples[sample]['d13C_VPDB']:.2f}",
2086				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
2087				f"{self.samples[sample][f'D{self._4x}']:.4f}",
2088				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
2089				f{self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
2090				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
2091				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
2092				]]
2093		if save_to_file:
2094			if not os.path.exists(dir):
2095				os.makedirs(dir)
2096			if filename is None:
2097				filename = f'D{self._4x}_samples.csv'
2098			with open(f'{dir}/{filename}', 'w') as fid:
2099				fid.write(make_csv(out))
2100		if print_out:
2101			self.msg('\n'+pretty_table(out))
2102		if output == 'raw':
2103			return out
2104		elif output == 'pretty':
2105			return pretty_table(out)
2106
2107
2108	def plot_sessions(self, dir = 'output', figsize = (8,8)):
2109		'''
2110		Generate session plots and save them to disk.
2111
2112		**Parameters**
2113
2114		+ `dir`: the directory in which to save the plots
2115		+ `figsize`: the width and height (in inches) of each plot
2116		'''
2117		if not os.path.exists(dir):
2118			os.makedirs(dir)
2119
2120		for session in self.sessions:
2121			sp = self.plot_single_session(session, xylimits = 'constant')
2122			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
2123			ppl.close(sp.fig)
2124
2125
2126	@make_verbal
2127	def consolidate_samples(self):
2128		'''
2129		Compile various statistics for each sample.
2130
2131		For each anchor sample:
2132
2133		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
2134		+ `SE_D47` or `SE_D48`: set to zero by definition
2135
2136		For each unknown sample:
2137
2138		+ `D47` or `D48`: the standardized Δ4x value for this unknown
2139		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown
2140
2141		For each anchor and unknown:
2142
2143		+ `N`: the total number of analyses of this sample
2144		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
2145		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
2146		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
2147		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
2148		variance, indicating whether the Δ4x repeatability this sample differs significantly from
2149		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
2150		'''
2151		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
2152		for sample in self.samples:
2153			self.samples[sample]['N'] = len(self.samples[sample]['data'])
2154			if self.samples[sample]['N'] > 1:
2155				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
2156
2157			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
2158			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
2159
2160			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
2161			if len(D4x_pop) > 2:
2162				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
2163
2164		if self.standardization_method == 'pooled':
2165			for sample in self.anchors:
2166				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
2167				self.samples[sample][f'SE_D{self._4x}'] = 0.
2168			for sample in self.unknowns:
2169				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
2170				try:
2171					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
2172				except ValueError:
2173					# when `sample` is constrained by self.standardize(constraints = {...}),
2174					# it is no longer listed in self.standardization.var_names.
2175					# Temporary fix: define SE as zero for now
2176					self.samples[sample][f'SE_D4{self._4x}'] = 0.
2177
2178		elif self.standardization_method == 'indep_sessions':
2179			for sample in self.anchors:
2180				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
2181				self.samples[sample][f'SE_D{self._4x}'] = 0.
2182			for sample in self.unknowns:
2183				self.msg(f'Consolidating sample {sample}')
2184				self.unknowns[sample][f'session_D{self._4x}'] = {}
2185				session_avg = []
2186				for session in self.sessions:
2187					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
2188					if sdata:
2189						self.msg(f'{sample} found in session {session}')
2190						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
2191						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
2192						# !! TODO: sigma_s below does not account for temporal changes in standardization error
2193						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
2194						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
2195						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
2196						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
2197				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
2198				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
2199				wsum = sum([weights[s] for s in weights])
2200				for s in weights:
2201					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]
2202
2203
2204	def consolidate_sessions(self):
2205		'''
2206		Compute various statistics for each session.
2207
2208		+ `Na`: Number of anchor analyses in the session
2209		+ `Nu`: Number of unknown analyses in the session
2210		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
2211		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
2212		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
2213		+ `a`: scrambling factor
2214		+ `b`: compositional slope
2215		+ `c`: WG offset
2216		+ `SE_a`: Model stadard erorr of `a`
2217		+ `SE_b`: Model stadard erorr of `b`
2218		+ `SE_c`: Model stadard erorr of `c`
2219		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
2220		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
2221		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
2222		+ `a2`: scrambling factor drift
2223		+ `b2`: compositional slope drift
2224		+ `c2`: WG offset drift
2225		+ `Np`: Number of standardization parameters to fit
2226		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
2227		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
2228		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
2229		'''
2230		for session in self.sessions:
2231			if 'd13Cwg_VPDB' not in self.sessions[session]:
2232				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
2233			if 'd18Owg_VSMOW' not in self.sessions[session]:
2234				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
2235			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
2236			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
2237
2238			self.msg(f'Computing repeatabilities for session {session}')
2239			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
2240			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
2241			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
2242
2243		if self.standardization_method == 'pooled':
2244			for session in self.sessions:
2245
2246				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
2247				i = self.standardization.var_names.index(f'a_{pf(session)}')
2248				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
2249
2250				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
2251				i = self.standardization.var_names.index(f'b_{pf(session)}')
2252				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
2253
2254				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
2255				i = self.standardization.var_names.index(f'c_{pf(session)}')
2256				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
2257
2258				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
2259				if self.sessions[session]['scrambling_drift']:
2260					i = self.standardization.var_names.index(f'a2_{pf(session)}')
2261					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
2262				else:
2263					self.sessions[session]['SE_a2'] = 0.
2264
2265				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
2266				if self.sessions[session]['slope_drift']:
2267					i = self.standardization.var_names.index(f'b2_{pf(session)}')
2268					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
2269				else:
2270					self.sessions[session]['SE_b2'] = 0.
2271
2272				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
2273				if self.sessions[session]['wg_drift']:
2274					i = self.standardization.var_names.index(f'c2_{pf(session)}')
2275					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
2276				else:
2277					self.sessions[session]['SE_c2'] = 0.
2278
2279				i = self.standardization.var_names.index(f'a_{pf(session)}')
2280				j = self.standardization.var_names.index(f'b_{pf(session)}')
2281				k = self.standardization.var_names.index(f'c_{pf(session)}')
2282				CM = np.zeros((6,6))
2283				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
2284				try:
2285					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
2286					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
2287					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
2288					try:
2289						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
2290						CM[3,4] = self.standardization.covar[i2,j2]
2291						CM[4,3] = self.standardization.covar[j2,i2]
2292					except ValueError:
2293						pass
2294					try:
2295						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2296						CM[3,5] = self.standardization.covar[i2,k2]
2297						CM[5,3] = self.standardization.covar[k2,i2]
2298					except ValueError:
2299						pass
2300				except ValueError:
2301					pass
2302				try:
2303					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
2304					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
2305					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
2306					try:
2307						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2308						CM[4,5] = self.standardization.covar[j2,k2]
2309						CM[5,4] = self.standardization.covar[k2,j2]
2310					except ValueError:
2311						pass
2312				except ValueError:
2313					pass
2314				try:
2315					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2316					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
2317					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
2318				except ValueError:
2319					pass
2320
2321				self.sessions[session]['CM'] = CM
2322
2323		elif self.standardization_method == 'indep_sessions':
2324			pass # Not implemented yet
2325
2326
2327	@make_verbal
2328	def repeatabilities(self):
2329		'''
2330		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
2331		(for all samples, for anchors, and for unknowns).
2332		'''
2333		self.msg('Computing reproducibilities for all sessions')
2334
2335		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
2336		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
2337		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
2338		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
2339		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
2340
2341
2342	@make_verbal
2343	def consolidate(self, tables = True, plots = True):
2344		'''
2345		Collect information about samples, sessions and repeatabilities.
2346		'''
2347		self.consolidate_samples()
2348		self.consolidate_sessions()
2349		self.repeatabilities()
2350
2351		if tables:
2352			self.summary()
2353			self.table_of_sessions()
2354			self.table_of_analyses()
2355			self.table_of_samples()
2356
2357		if plots:
2358			self.plot_sessions()
2359
2360
2361	@make_verbal
2362	def rmswd(self,
2363		samples = 'all samples',
2364		sessions = 'all sessions',
2365		):
2366		'''
2367		Compute the χ2, root mean squared weighted deviation
2368		(i.e. reduced χ2), and corresponding degrees of freedom of the
2369		Δ4x values for samples in `samples` and sessions in `sessions`.
2370		
2371		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
2372		'''
2373		if samples == 'all samples':
2374			mysamples = [k for k in self.samples]
2375		elif samples == 'anchors':
2376			mysamples = [k for k in self.anchors]
2377		elif samples == 'unknowns':
2378			mysamples = [k for k in self.unknowns]
2379		else:
2380			mysamples = samples
2381
2382		if sessions == 'all sessions':
2383			sessions = [k for k in self.sessions]
2384
2385		chisq, Nf = 0, 0
2386		for sample in mysamples :
2387			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2388			if len(G) > 1 :
2389				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
2390				Nf += (len(G) - 1)
2391				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
2392		r = (chisq / Nf)**.5 if Nf > 0 else 0
2393		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
2394		return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}
2395
2396	
2397	@make_verbal
2398	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
2399		'''
2400		Compute the repeatability of `[r[key] for r in self]`
2401		'''
2402		# NB: it's debatable whether rD47 should be computed
2403		# with Nf = len(self)-len(self.samples) instead of
2404		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
2405
2406		if samples == 'all samples':
2407			mysamples = [k for k in self.samples]
2408		elif samples == 'anchors':
2409			mysamples = [k for k in self.anchors]
2410		elif samples == 'unknowns':
2411			mysamples = [k for k in self.unknowns]
2412		else:
2413			mysamples = samples
2414
2415		if sessions == 'all sessions':
2416			sessions = [k for k in self.sessions]
2417
2418		if key in ['D47', 'D48']:
2419			chisq, Nf = 0, 0
2420			for sample in mysamples :
2421				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2422				if len(X) > 1 :
2423					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
2424					if sample in self.unknowns:
2425						Nf += len(X) - 1
2426					else:
2427						Nf += len(X)
2428			if samples in ['anchors', 'all samples']:
2429				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
2430			r = (chisq / Nf)**.5 if Nf > 0 else 0
2431
2432		else: # if key not in ['D47', 'D48']
2433			chisq, Nf = 0, 0
2434			for sample in mysamples :
2435				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2436				if len(X) > 1 :
2437					Nf += len(X) - 1
2438					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
2439			r = (chisq / Nf)**.5 if Nf > 0 else 0
2440
2441		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
2442		return r
2443
2444	def sample_average(self, samples, weights = 'equal', normalize = True):
2445		'''
2446		Weighted average Δ4x value of a group of samples, accounting for covariance.
2447
2448		Returns the weighed average Δ4x value and associated SE
2449		of a group of samples. Weights are equal by default. If `normalize` is
2450		true, `weights` will be rescaled so that their sum equals 1.
2451
2452		**Examples**
2453
2454		```python
2455		self.sample_average(['X','Y'], [1, 2])
2456		```
2457
2458		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
2459		where Δ4x(X) and Δ4x(Y) are the average Δ4x
2460		values of samples X and Y, respectively.
2461
2462		```python
2463		self.sample_average(['X','Y'], [1, -1], normalize = False)
2464		```
2465
2466		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
2467		'''
2468		if weights == 'equal':
2469			weights = [1/len(samples)] * len(samples)
2470
2471		if normalize:
2472			s = sum(weights)
2473			if s:
2474				weights = [w/s for w in weights]
2475
2476		try:
2477# 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
2478# 			C = self.standardization.covar[indices,:][:,indices]
2479			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
2480			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
2481			return correlated_sum(X, C, weights)
2482		except ValueError:
2483			return (0., 0.)
2484
2485
2486	def sample_D4x_covar(self, sample1, sample2 = None):
2487		'''
2488		Covariance between Δ4x values of samples
2489
2490		Returns the error covariance between the average Δ4x values of two
2491		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
2492		returns the Δ4x variance for that sample.
2493		'''
2494		if sample2 is None:
2495			sample2 = sample1
2496		if self.standardization_method == 'pooled':
2497			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
2498			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
2499			return self.standardization.covar[i, j]
2500		elif self.standardization_method == 'indep_sessions':
2501			if sample1 == sample2:
2502				return self.samples[sample1][f'SE_D{self._4x}']**2
2503			else:
2504				c = 0
2505				for session in self.sessions:
2506					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
2507					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
2508					if sdata1 and sdata2:
2509						a = self.sessions[session]['a']
2510						# !! TODO: CM below does not account for temporal changes in standardization parameters
2511						CM = self.sessions[session]['CM'][:3,:3]
2512						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
2513						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
2514						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
2515						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
2516						c += (
2517							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
2518							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
2519							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
2520							@ CM
2521							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
2522							) / a**2
2523				return float(c)
2524
2525	def sample_D4x_correl(self, sample1, sample2 = None):
2526		'''
2527		Correlation between Δ4x errors of samples
2528
2529		Returns the error correlation between the average Δ4x values of two samples.
2530		'''
2531		if sample2 is None or sample2 == sample1:
2532			return 1.
2533		return (
2534			self.sample_D4x_covar(sample1, sample2)
2535			/ self.unknowns[sample1][f'SE_D{self._4x}']
2536			/ self.unknowns[sample2][f'SE_D{self._4x}']
2537			)
2538
2539	def plot_single_session(self,
2540		session,
2541		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
2542		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
2543		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
2544		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
2545		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
2546		xylimits = 'free', # | 'constant'
2547		x_label = None,
2548		y_label = None,
2549		error_contour_interval = 'auto',
2550		fig = 'new',
2551		):
2552		'''
2553		Generate plot for a single session
2554		'''
2555		if x_label is None:
2556			x_label = f'δ$_{{{self._4x}}}$ (‰)'
2557		if y_label is None:
2558			y_label = f'Δ$_{{{self._4x}}}$ (‰)'
2559
2560		out = _SessionPlot()
2561		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
2562		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
2563		
2564		if fig == 'new':
2565			out.fig = ppl.figure(figsize = (6,6))
2566			ppl.subplots_adjust(.1,.1,.9,.9)
2567
2568		out.anchor_analyses, = ppl.plot(
2569			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
2570			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
2571			**kw_plot_anchors)
2572		out.unknown_analyses, = ppl.plot(
2573			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
2574			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
2575			**kw_plot_unknowns)
2576		out.anchor_avg = ppl.plot(
2577			np.array([ np.array([
2578				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
2579				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
2580				]) for sample in anchors]).T,
2581			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
2582			**kw_plot_anchor_avg)
2583		out.unknown_avg = ppl.plot(
2584			np.array([ np.array([
2585				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
2586				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
2587				]) for sample in unknowns]).T,
2588			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
2589			**kw_plot_unknown_avg)
2590		if xylimits == 'constant':
2591			x = [r[f'd{self._4x}'] for r in self]
2592			y = [r[f'D{self._4x}'] for r in self]
2593			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
2594			w, h = x2-x1, y2-y1
2595			x1 -= w/20
2596			x2 += w/20
2597			y1 -= h/20
2598			y2 += h/20
2599			ppl.axis([x1, x2, y1, y2])
2600		elif xylimits == 'free':
2601			x1, x2, y1, y2 = ppl.axis()
2602		else:
2603			x1, x2, y1, y2 = ppl.axis(xylimits)
2604				
2605		if error_contour_interval != 'none':
2606			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
2607			XI,YI = np.meshgrid(xi, yi)
2608			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
2609			if error_contour_interval == 'auto':
2610				rng = np.max(SI) - np.min(SI)
2611				if rng <= 0.01:
2612					cinterval = 0.001
2613				elif rng <= 0.03:
2614					cinterval = 0.004
2615				elif rng <= 0.1:
2616					cinterval = 0.01
2617				elif rng <= 0.3:
2618					cinterval = 0.03
2619				elif rng <= 1.:
2620					cinterval = 0.1
2621				else:
2622					cinterval = 0.5
2623			else:
2624				cinterval = error_contour_interval
2625
2626			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
2627			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
2628			out.clabel = ppl.clabel(out.contour)
2629
2630		ppl.xlabel(x_label)
2631		ppl.ylabel(y_label)
2632		ppl.title(session, weight = 'bold')
2633		ppl.grid(alpha = .2)
2634		out.ax = ppl.gca()		
2635
2636		return out
2637
2638	def plot_residuals(
2639		self,
2640		hist = False,
2641		binwidth = 2/3,
2642		dir = 'output',
2643		filename = None,
2644		highlight = [],
2645		colors = None,
2646		figsize = None,
2647		):
2648		'''
2649		Plot residuals of each analysis as a function of time (actually, as a function of
2650		the order of analyses in the `D4xdata` object)
2651
2652		+ `hist`: whether to add a histogram of residuals
2653		+ `histbins`: specify bin edges for the histogram
2654		+ `dir`: the directory in which to save the plot
2655		+ `highlight`: a list of samples to highlight
2656		+ `colors`: a dict of `{<sample>: <color>}` for all samples
2657		+ `figsize`: (width, height) of figure
2658		'''
2659		# Layout
2660		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
2661		if hist:
2662			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
2663			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
2664		else:
2665			ppl.subplots_adjust(.08,.05,.78,.8)
2666			ax1 = ppl.subplot(111)
2667		
2668		# Colors
2669		N = len(self.anchors)
2670		if colors is None:
2671			if len(highlight) > 0:
2672				Nh = len(highlight)
2673				if Nh == 1:
2674					colors = {highlight[0]: (0,0,0)}
2675				elif Nh == 3:
2676					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
2677				elif Nh == 4:
2678					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
2679				else:
2680					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
2681			else:
2682				if N == 3:
2683					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
2684				elif N == 4:
2685					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
2686				else:
2687					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}
2688
2689		ppl.sca(ax1)
2690		
2691		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)
2692
2693		session = self[0]['Session']
2694		x1 = 0
2695# 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
2696		x_sessions = {}
2697		one_or_more_singlets = False
2698		one_or_more_multiplets = False
2699		multiplets = set()
2700		for k,r in enumerate(self):
2701			if r['Session'] != session:
2702				x2 = k-1
2703				x_sessions[session] = (x1+x2)/2
2704				ppl.axvline(k - 0.5, color = 'k', lw = .5)
2705				session = r['Session']
2706				x1 = k
2707			singlet = len(self.samples[r['Sample']]['data']) == 1
2708			if not singlet:
2709				multiplets.add(r['Sample'])
2710			if r['Sample'] in self.unknowns:
2711				if singlet:
2712					one_or_more_singlets = True
2713				else:
2714					one_or_more_multiplets = True
2715			kw = dict(
2716				marker = 'x' if singlet else '+',
2717				ms = 4 if singlet else 5,
2718				ls = 'None',
2719				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
2720				mew = 1,
2721				alpha = 0.2 if singlet else 1,
2722				)
2723			if highlight and r['Sample'] not in highlight:
2724				kw['alpha'] = 0.2
2725			ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
2726		x2 = k
2727		x_sessions[session] = (x1+x2)/2
2728
2729		ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
2730		ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
2731		if not hist:
2732			ppl.text(len(self), self.repeatability['r_D47']*1000, f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
2733			ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f"   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center')
2734
2735		xmin, xmax, ymin, ymax = ppl.axis()
2736		for s in x_sessions:
2737			ppl.text(
2738				x_sessions[s],
2739				ymax +1,
2740				s,
2741				va = 'bottom',
2742				**(
2743					dict(ha = 'center')
2744					if len(self.sessions[s]['data']) > (0.15 * len(self))
2745					else dict(ha = 'left', rotation = 45)
2746					)
2747				)
2748
2749		if hist:
2750			ppl.sca(ax2)
2751
2752		for s in colors:
2753			kw['marker'] = '+'
2754			kw['ms'] = 5
2755			kw['mec'] = colors[s]
2756			kw['label'] = s
2757			kw['alpha'] = 1
2758			ppl.plot([], [], **kw)
2759
2760		kw['mec'] = (0,0,0)
2761
2762		if one_or_more_singlets:
2763			kw['marker'] = 'x'
2764			kw['ms'] = 4
2765			kw['alpha'] = .2
2766			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
2767			ppl.plot([], [], **kw)
2768
2769		if one_or_more_multiplets:
2770			kw['marker'] = '+'
2771			kw['ms'] = 4
2772			kw['alpha'] = 1
2773			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
2774			ppl.plot([], [], **kw)
2775
2776		if hist:
2777			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
2778		else:
2779			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
2780		leg.set_zorder(-1000)
2781
2782		ppl.sca(ax1)
2783
2784		ppl.ylabel('Δ$_{47}$ residuals (ppm)')
2785		ppl.xticks([])
2786		ppl.axis([-1, len(self), None, None])
2787
2788		if hist:
2789			ppl.sca(ax2)
2790			X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self if r['Sample'] in multiplets]
2791			ppl.hist(
2792				X,
2793				orientation = 'horizontal',
2794				histtype = 'stepfilled',
2795				ec = [.4]*3,
2796				fc = [.25]*3,
2797				alpha = .25,
2798				bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
2799				)
2800			ppl.axis([None, None, ymin, ymax])
2801			ppl.text(0, 0,
2802				f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm\n   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
2803				size = 8,
2804				alpha = 1,
2805				va = 'center',
2806				ha = 'left',
2807				)
2808
2809			ppl.xticks([])
2810			ppl.yticks([])
2811# 			ax2.spines['left'].set_visible(False)
2812			ax2.spines['right'].set_visible(False)
2813			ax2.spines['top'].set_visible(False)
2814			ax2.spines['bottom'].set_visible(False)
2815
2816
2817		if not os.path.exists(dir):
2818			os.makedirs(dir)
2819		if filename is None:
2820			return fig
2821		elif filename == '':
2822			filename = f'D{self._4x}_residuals.pdf'
2823		ppl.savefig(f'{dir}/{filename}')
2824		ppl.close(fig)
2825				
2826
2827	def simulate(self, *args, **kwargs):
2828		'''
2829		Legacy function with warning message pointing to `virtual_data()`
2830		'''
2831		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
2832
2833	def plot_distribution_of_analyses(
2834		self,
2835		dir = 'output',
2836		filename = None,
2837		vs_time = False,
2838		figsize = (6,4),
2839		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
2840		output = None,
2841		):
2842		'''
2843		Plot temporal distribution of all analyses in the data set.
2844		
2845		**Parameters**
2846
2847		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
2848		'''
2849
2850		asamples = [s for s in self.anchors]
2851		usamples = [s for s in self.unknowns]
2852		if output is None or output == 'fig':
2853			fig = ppl.figure(figsize = figsize)
2854			ppl.subplots_adjust(*subplots_adjust)
2855		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
2856		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
2857		Xmax += (Xmax-Xmin)/40
2858		Xmin -= (Xmax-Xmin)/41
2859		for k, s in enumerate(asamples + usamples):
2860			if vs_time:
2861				X = [r['TimeTag'] for r in self if r['Sample'] == s]
2862			else:
2863				X = [x for x,r in enumerate(self) if r['Sample'] == s]
2864			Y = [-k for x in X]
2865			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
2866			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
2867			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
2868		ppl.axis([Xmin, Xmax, -k-1, 1])
2869		ppl.xlabel('\ntime')
2870		ppl.gca().annotate('',
2871			xy = (0.6, -0.02),
2872			xycoords = 'axes fraction',
2873			xytext = (.4, -0.02), 
2874            arrowprops = dict(arrowstyle = "->", color = 'k'),
2875            )
2876			
2877
2878		x2 = -1
2879		for session in self.sessions:
2880			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
2881			if vs_time:
2882				ppl.axvline(x1, color = 'k', lw = .75)
2883			if x2 > -1:
2884				if not vs_time:
2885					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
2886			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
2887# 			from xlrd import xldate_as_datetime
2888# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
2889			if vs_time:
2890				ppl.axvline(x2, color = 'k', lw = .75)
2891				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
2892			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
2893
2894		ppl.xticks([])
2895		ppl.yticks([])
2896
2897		if output is None:
2898			if not os.path.exists(dir):
2899				os.makedirs(dir)
2900			if filename == None:
2901				filename = f'D{self._4x}_distribution_of_analyses.pdf'
2902			ppl.savefig(f'{dir}/{filename}')
2903			ppl.close(fig)
2904		elif output == 'ax':
2905			return ppl.gca()
2906		elif output == 'fig':
2907			return fig
2908
2909
2910class D47data(D4xdata):
2911	'''
2912	Store and process data for a large set of Δ47 analyses,
2913	usually comprising more than one analytical session.
2914	'''
2915
2916	Nominal_D4x = {
2917		'ETH-1':   0.2052,
2918		'ETH-2':   0.2085,
2919		'ETH-3':   0.6132,
2920		'ETH-4':   0.4511,
2921		'IAEA-C1': 0.3018,
2922		'IAEA-C2': 0.6409,
2923		'MERCK':   0.5135,
2924		} # I-CDES (Bernasconi et al., 2021)
2925	'''
2926	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
2927	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
2928	reference frame.
2929
2930	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
2931	```py
2932	{
2933		'ETH-1'   : 0.2052,
2934		'ETH-2'   : 0.2085,
2935		'ETH-3'   : 0.6132,
2936		'ETH-4'   : 0.4511,
2937		'IAEA-C1' : 0.3018,
2938		'IAEA-C2' : 0.6409,
2939		'MERCK'   : 0.5135,
2940	}
2941	```
2942	'''
2943
2944
2945	@property
2946	def Nominal_D47(self):
2947		return self.Nominal_D4x
2948	
2949
2950	@Nominal_D47.setter
2951	def Nominal_D47(self, new):
2952		self.Nominal_D4x = dict(**new)
2953		self.refresh()
2954
2955
2956	def __init__(self, l = [], **kwargs):
2957		'''
2958		**Parameters:** same as `D4xdata.__init__()`
2959		'''
2960		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
2961
2962
2963	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
2964		'''
2965		Find all samples for which `Teq` is specified, compute equilibrium Δ47
2966		value for that temperature, and add treat these samples as additional anchors.
2967
2968		**Parameters**
2969
2970		+ `fCo2eqD47`: Which CO2 equilibrium law to use
2971		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
2972		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
2973		+ `priority`: if `replace`: forget old anchors and only use the new ones;
2974		if `new`: keep pre-existing anchors but update them in case of conflict
2975		between old and new Δ47 values;
2976		if `old`: keep pre-existing anchors but preserve their original Δ47
2977		values in case of conflict.
2978		'''
2979		f = {
2980			'petersen': fCO2eqD47_Petersen,
2981			'wang': fCO2eqD47_Wang,
2982			}[fCo2eqD47]
2983		foo = {}
2984		for r in self:
2985			if 'Teq' in r:
2986				if r['Sample'] in foo:
2987					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
2988				else:
2989					foo[r['Sample']] = f(r['Teq'])
2990			else:
2991					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
2992
2993		if priority == 'replace':
2994			self.Nominal_D47 = {}
2995		for s in foo:
2996			if priority != 'old' or s not in self.Nominal_D47:
2997				self.Nominal_D47[s] = foo[s]
2998	
2999
3000
3001
3002class D48data(D4xdata):
3003	'''
3004	Store and process data for a large set of Δ48 analyses,
3005	usually comprising more than one analytical session.
3006	'''
3007
3008	Nominal_D4x = {
3009		'ETH-1':  0.138,
3010		'ETH-2':  0.138,
3011		'ETH-3':  0.270,
3012		'ETH-4':  0.223,
3013		'GU-1':  -0.419,
3014		} # (Fiebig et al., 2019, 2021)
3015	'''
3016	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
3017	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
3018	reference frame.
3019
3020	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
3021	Fiebig et al. (in press)):
3022
3023	```py
3024	{
3025		'ETH-1' :  0.138,
3026		'ETH-2' :  0.138,
3027		'ETH-3' :  0.270,
3028		'ETH-4' :  0.223,
3029		'GU-1'  : -0.419,
3030	}
3031	```
3032	'''
3033
3034
3035	@property
3036	def Nominal_D48(self):
3037		return self.Nominal_D4x
3038
3039	
3040	@Nominal_D48.setter
3041	def Nominal_D48(self, new):
3042		self.Nominal_D4x = dict(**new)
3043		self.refresh()
3044
3045
3046	def __init__(self, l = [], **kwargs):
3047		'''
3048		**Parameters:** same as `D4xdata.__init__()`
3049		'''
3050		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
3051
3052
3053class _SessionPlot():
3054	'''
3055	Simple placeholder class
3056	'''
3057	def __init__(self):
3058		pass
=======
  14
  15## API Documentation
  16'''
  17
  18__docformat__ = "restructuredtext"
  19__author__    = 'Mathieu Daëron'
  20__contact__   = 'daeron@lsce.ipsl.fr'
  21__copyright__ = 'Copyright (c) 2023 Mathieu Daëron'
  22__license__   = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause'
  23__date__      = '2023-05-11'
  24__version__   = '2.0.4'
  25
  26import os
  27import numpy as np
  28from statistics import stdev
  29from scipy.stats import t as tstudent
  30from scipy.stats import levene
  31from scipy.interpolate import interp1d
  32from numpy import linalg
  33from lmfit import Minimizer, Parameters, report_fit
  34from matplotlib import pyplot as ppl
  35from datetime import datetime as dt
  36from functools import wraps
  37from colorsys import hls_to_rgb
  38from matplotlib import rcParams
  39
  40rcParams['font.family'] = 'sans-serif'
  41rcParams['font.sans-serif'] = 'Helvetica'
  42rcParams['font.size'] = 10
  43rcParams['mathtext.fontset'] = 'custom'
  44rcParams['mathtext.rm'] = 'sans'
  45rcParams['mathtext.bf'] = 'sans:bold'
  46rcParams['mathtext.it'] = 'sans:italic'
  47rcParams['mathtext.cal'] = 'sans:italic'
  48rcParams['mathtext.default'] = 'rm'
  49rcParams['xtick.major.size'] = 4
  50rcParams['xtick.major.width'] = 1
  51rcParams['ytick.major.size'] = 4
  52rcParams['ytick.major.width'] = 1
  53rcParams['axes.grid'] = False
  54rcParams['axes.linewidth'] = 1
  55rcParams['grid.linewidth'] = .75
  56rcParams['grid.linestyle'] = '-'
  57rcParams['grid.alpha'] = .15
  58rcParams['savefig.dpi'] = 150
  59
  60Petersen_etal_CO2eqD47 = np.array([[-12, 1.147113572], [-11, 1.139961218], [-10, 1.132872856], [-9, 1.125847677], [-8, 1.118884889], [-7, 1.111983708], [-6, 1.105143366], [-5, 1.098363105], [-4, 1.091642182], [-3, 1.084979862], [-2, 1.078375423], [-1, 1.071828156], [0, 1.065337360], [1, 1.058902349], [2, 1.052522443], [3, 1.046196976], [4, 1.039925291], [5, 1.033706741], [6, 1.027540690], [7, 1.021426510], [8, 1.015363585], [9, 1.009351306], [10, 1.003389075], [11, 0.997476303], [12, 0.991612409], [13, 0.985796821], [14, 0.980028975], [15, 0.974308318], [16, 0.968634304], [17, 0.963006392], [18, 0.957424055], [19, 0.951886769], [20, 0.946394020], [21, 0.940945302], [22, 0.935540114], [23, 0.930177964], [24, 0.924858369], [25, 0.919580851], [26, 0.914344938], [27, 0.909150167], [28, 0.903996080], [29, 0.898882228], [30, 0.893808167], [31, 0.888773459], [32, 0.883777672], [33, 0.878820382], [34, 0.873901170], [35, 0.869019623], [36, 0.864175334], [37, 0.859367901], [38, 0.854596929], [39, 0.849862028], [40, 0.845162813], [41, 0.840498905], [42, 0.835869931], [43, 0.831275522], [44, 0.826715314], [45, 0.822188950], [46, 0.817696075], [47, 0.813236341], [48, 0.808809404], [49, 0.804414926], [50, 0.800052572], [51, 0.795722012], [52, 0.791422922], [53, 0.787154979], [54, 0.782917869], [55, 0.778711277], [56, 0.774534898], [57, 0.770388426], [58, 0.766271562], [59, 0.762184010], [60, 0.758125479], [61, 0.754095680], [62, 0.750094329], [63, 0.746121147], [64, 0.742175856], [65, 0.738258184], [66, 0.734367860], [67, 0.730504620], [68, 0.726668201], [69, 0.722858343], [70, 0.719074792], [71, 0.715317295], [72, 0.711585602], [73, 0.707879469], [74, 0.704198652], [75, 0.700542912], [76, 0.696912012], [77, 0.693305719], [78, 0.689723802], [79, 0.686166034], [80, 0.682632189], [81, 0.679122047], [82, 0.675635387], [83, 0.672171994], [84, 0.668731654], [85, 0.665314156], [86, 0.661919291], [87, 0.658546854], [88, 0.655196641], [89, 0.651868451], [90, 0.648562087], [91, 0.645277352], [92, 0.642014054], [93, 0.638771999], [94, 0.635551001], [95, 0.632350872], [96, 0.629171428], [97, 0.626012487], [98, 0.622873870], [99, 0.619755397], [100, 0.616656895], [102, 0.610519107], [104, 0.604459143], [106, 0.598475670], [108, 0.592567388], [110, 0.586733026], [112, 0.580971342], [114, 0.575281125], [116, 0.569661187], [118, 0.564110371], [120, 0.558627545], [122, 0.553211600], [124, 0.547861454], [126, 0.542576048], [128, 0.537354347], [130, 0.532195337], [132, 0.527098028], [134, 0.522061450], [136, 0.517084654], [138, 0.512166711], [140, 0.507306712], [142, 0.502503768], [144, 0.497757006], [146, 0.493065573], [148, 0.488428634], [150, 0.483845370], [152, 0.479314980], [154, 0.474836677], [156, 0.470409692], [158, 0.466033271], [160, 0.461706674], [162, 0.457429176], [164, 0.453200067], [166, 0.449018650], [168, 0.444884242], [170, 0.440796174], [172, 0.436753787], [174, 0.432756438], [176, 0.428803494], [178, 0.424894334], [180, 0.421028350], [182, 0.417204944], [184, 0.413423530], [186, 0.409683531], [188, 0.405984383], [190, 0.402325531], [192, 0.398706429], [194, 0.395126543], [196, 0.391585347], [198, 0.388082324], [200, 0.384616967], [202, 0.381188778], [204, 0.377797268], [206, 0.374441954], [208, 0.371122364], [210, 0.367838033], [212, 0.364588505], [214, 0.361373329], [216, 0.358192065], [218, 0.355044277], [220, 0.351929540], [222, 0.348847432], [224, 0.345797540], [226, 0.342779460], [228, 0.339792789], [230, 0.336837136], [232, 0.333912113], [234, 0.331017339], [236, 0.328152439], [238, 0.325317046], [240, 0.322510795], [242, 0.319733329], [244, 0.316984297], [246, 0.314263352], [248, 0.311570153], [250, 0.308904364], [252, 0.306265654], [254, 0.303653699], [256, 0.301068176], [258, 0.298508771], [260, 0.295975171], [262, 0.293467070], [264, 0.290984167], [266, 0.288526163], [268, 0.286092765], [270, 0.283683684], [272, 0.281298636], [274, 0.278937339], [276, 0.276599517], [278, 0.274284898], [280, 0.271993211], [282, 0.269724193], [284, 0.267477582], [286, 0.265253121], [288, 0.263050554], [290, 0.260869633], [292, 0.258710110], [294, 0.256571741], [296, 0.254454286], [298, 0.252357508], [300, 0.250281174], [302, 0.248225053], [304, 0.246188917], [306, 0.244172542], [308, 0.242175707], [310, 0.240198194], [312, 0.238239786], [314, 0.236300272], [316, 0.234379441], [318, 0.232477087], [320, 0.230593005], [322, 0.228726993], [324, 0.226878853], [326, 0.225048388], [328, 0.223235405], [330, 0.221439711], [332, 0.219661118], [334, 0.217899439], [336, 0.216154491], [338, 0.214426091], [340, 0.212714060], [342, 0.211018220], [344, 0.209338398], [346, 0.207674420], [348, 0.206026115], [350, 0.204393315], [355, 0.200378063], [360, 0.196456139], [365, 0.192625077], [370, 0.188882487], [375, 0.185226048], [380, 0.181653511], [385, 0.178162694], [390, 0.174751478], [395, 0.171417807], [400, 0.168159686], [405, 0.164975177], [410, 0.161862398], [415, 0.158819521], [420, 0.155844772], [425, 0.152936426], [430, 0.150092806], [435, 0.147312286], [440, 0.144593281], [445, 0.141934254], [450, 0.139333710], [455, 0.136790195], [460, 0.134302294], [465, 0.131868634], [470, 0.129487876], [475, 0.127158722], [480, 0.124879906], [485, 0.122650197], [490, 0.120468398], [495, 0.118333345], [500, 0.116243903], [505, 0.114198970], [510, 0.112197471], [515, 0.110238362], [520, 0.108320625], [525, 0.106443271], [530, 0.104605335], [535, 0.102805877], [540, 0.101043985], [545, 0.099318768], [550, 0.097629359], [555, 0.095974915], [560, 0.094354612], [565, 0.092767650], [570, 0.091213248], [575, 0.089690648], [580, 0.088199108], [585, 0.086737906], [590, 0.085306341], [595, 0.083903726], [600, 0.082529395], [605, 0.081182697], [610, 0.079862998], [615, 0.078569680], [620, 0.077302141], [625, 0.076059794], [630, 0.074842066], [635, 0.073648400], [640, 0.072478251], [645, 0.071331090], [650, 0.070206399], [655, 0.069103674], [660, 0.068022424], [665, 0.066962168], [670, 0.065922439], [675, 0.064902780], [680, 0.063902748], [685, 0.062921909], [690, 0.061959837], [695, 0.061016122], [700, 0.060090360], [705, 0.059182157], [710, 0.058291131], [715, 0.057416907], [720, 0.056559120], [725, 0.055717414], [730, 0.054891440], [735, 0.054080860], [740, 0.053285343], [745, 0.052504565], [750, 0.051738210], [755, 0.050985971], [760, 0.050247546], [765, 0.049522643], [770, 0.048810974], [775, 0.048112260], [780, 0.047426227], [785, 0.046752609], [790, 0.046091145], [795, 0.045441581], [800, 0.044803668], [805, 0.044177164], [810, 0.043561831], [815, 0.042957438], [820, 0.042363759], [825, 0.041780573], [830, 0.041207664], [835, 0.040644822], [840, 0.040091839], [845, 0.039548516], [850, 0.039014654], [855, 0.038490063], [860, 0.037974554], [865, 0.037467944], [870, 0.036970054], [875, 0.036480707], [880, 0.035999734], [885, 0.035526965], [890, 0.035062238], [895, 0.034605393], [900, 0.034156272], [905, 0.033714724], [910, 0.033280598], [915, 0.032853749], [920, 0.032434032], [925, 0.032021309], [930, 0.031615443], [935, 0.031216300], [940, 0.030823749], [945, 0.030437663], [950, 0.030057915], [955, 0.029684385], [960, 0.029316951], [965, 0.028955498], [970, 0.028599910], [975, 0.028250075], [980, 0.027905884], [985, 0.027567229], [990, 0.027234006], [995, 0.026906112], [1000, 0.026583445], [1005, 0.026265908], [1010, 0.025953405], [1015, 0.025645841], [1020, 0.025343124], [1025, 0.025045163], [1030, 0.024751871], [1035, 0.024463160], [1040, 0.024178947], [1045, 0.023899147], [1050, 0.023623680], [1055, 0.023352467], [1060, 0.023085429], [1065, 0.022822491], [1070, 0.022563577], [1075, 0.022308615], [1080, 0.022057533], [1085, 0.021810260], [1090, 0.021566729], [1095, 0.021326872], [1100, 0.021090622]])
  61_fCO2eqD47_Petersen = interp1d(Petersen_etal_CO2eqD47[:,0], Petersen_etal_CO2eqD47[:,1])
  62def fCO2eqD47_Petersen(T):
  63	'''
  64	CO2 equilibrium Δ47 value as a function of T (in degrees C)
  65	according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127).
  66
  67	'''
  68	return float(_fCO2eqD47_Petersen(T))
  69
  70
  71Wang_etal_CO2eqD47 = np.array([[-83., 1.8954], [-73., 1.7530], [-63., 1.6261], [-53., 1.5126], [-43., 1.4104], [-33., 1.3182], [-23., 1.2345], [-13., 1.1584], [-3., 1.0888], [7., 1.0251], [17., 0.9665], [27., 0.9125], [37., 0.8626], [47., 0.8164], [57., 0.7734], [67., 0.7334], [87., 0.6612], [97., 0.6286], [107., 0.5980], [117., 0.5693], [127., 0.5423], [137., 0.5169], [147., 0.4930], [157., 0.4704], [167., 0.4491], [177., 0.4289], [187., 0.4098], [197., 0.3918], [207., 0.3747], [217., 0.3585], [227., 0.3431], [237., 0.3285], [247., 0.3147], [257., 0.3015], [267., 0.2890], [277., 0.2771], [287., 0.2657], [297., 0.2550], [307., 0.2447], [317., 0.2349], [327., 0.2256], [337., 0.2167], [347., 0.2083], [357., 0.2002], [367., 0.1925], [377., 0.1851], [387., 0.1781], [397., 0.1714], [407., 0.1650], [417., 0.1589], [427., 0.1530], [437., 0.1474], [447., 0.1421], [457., 0.1370], [467., 0.1321], [477., 0.1274], [487., 0.1229], [497., 0.1186], [507., 0.1145], [517., 0.1105], [527., 0.1068], [537., 0.1031], [547., 0.0997], [557., 0.0963], [567., 0.0931], [577., 0.0901], [587., 0.0871], [597., 0.0843], [607., 0.0816], [617., 0.0790], [627., 0.0765], [637., 0.0741], [647., 0.0718], [657., 0.0695], [667., 0.0674], [677., 0.0654], [687., 0.0634], [697., 0.0615], [707., 0.0597], [717., 0.0579], [727., 0.0562], [737., 0.0546], [747., 0.0530], [757., 0.0515], [767., 0.0500], [777., 0.0486], [787., 0.0472], [797., 0.0459], [807., 0.0447], [817., 0.0435], [827., 0.0423], [837., 0.0411], [847., 0.0400], [857., 0.0390], [867., 0.0380], [877., 0.0370], [887., 0.0360], [897., 0.0351], [907., 0.0342], [917., 0.0333], [927., 0.0325], [937., 0.0317], [947., 0.0309], [957., 0.0302], [967., 0.0294], [977., 0.0287], [987., 0.0281], [997., 0.0274], [1007., 0.0268], [1017., 0.0261], [1027., 0.0255], [1037., 0.0249], [1047., 0.0244], [1057., 0.0238], [1067., 0.0233], [1077., 0.0228], [1087., 0.0223], [1097., 0.0218]])
  72_fCO2eqD47_Wang = interp1d(Wang_etal_CO2eqD47[:,0] - 0.15, Wang_etal_CO2eqD47[:,1])
  73def fCO2eqD47_Wang(T):
  74	'''
  75	CO2 equilibrium Δ47 value as a function of `T` (in degrees C)
  76	according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039)
  77	(supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)).
  78	'''
  79	return float(_fCO2eqD47_Wang(T))
  80
  81
  82def correlated_sum(X, C, w = None):
  83	'''
  84	Compute covariance-aware linear combinations
  85
  86	**Parameters**
  87	
  88	+ `X`: list or 1-D array of values to sum
  89	+ `C`: covariance matrix for the elements of `X`
  90	+ `w`: list or 1-D array of weights to apply to the elements of `X`
  91	       (all equal to 1 by default)
  92
  93	Return the sum (and its SE) of the elements of `X`, with optional weights equal
  94	to the elements of `w`, accounting for covariances between the elements of `X`.
  95	'''
  96	if w is None:
  97		w = [1 for x in X]
  98	return np.dot(w,X), (np.dot(w,np.dot(C,w)))**.5
  99
 100
 101def make_csv(x, hsep = ',', vsep = '\n'):
 102	'''
 103	Formats a list of lists of strings as a CSV
 104
 105	**Parameters**
 106
 107	+ `x`: the list of lists of strings to format
 108	+ `hsep`: the field separator (`,` by default)
 109	+ `vsep`: the line-ending convention to use (`\\n` by default)
 110
 111	**Example**
 112
 113	```py
 114	print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']]))
 115	```
 116
 117	outputs:
 118
 119	```py
 120	a,b,c
 121	d,e,f
 122	```
 123	'''
 124	return vsep.join([hsep.join(l) for l in x])
 125
 126
 127def pf(txt):
 128	'''
 129	Modify string `txt` to follow `lmfit.Parameter()` naming rules.
 130	'''
 131	return txt.replace('-','_').replace('.','_').replace(' ','_')
 132
 133
 134def smart_type(x):
 135	'''
 136	Tries to convert string `x` to a float if it includes a decimal point, or
 137	to an integer if it does not. If both attempts fail, return the original
 138	string unchanged.
 139	'''
 140	try:
 141		y = float(x)
 142	except ValueError:
 143		return x
 144	if '.' not in x:
 145		return int(y)
 146	return y
 147
 148
 149def pretty_table(x, header = 1, hsep = '  ', vsep = '–', align = '<'):
 150	'''
 151	Reads a list of lists of strings and outputs an ascii table
 152
 153	**Parameters**
 154
 155	+ `x`: a list of lists of strings
 156	+ `header`: the number of lines to treat as header lines
 157	+ `hsep`: the horizontal separator between columns
 158	+ `vsep`: the character to use as vertical separator
 159	+ `align`: string of left (`<`) or right (`>`) alignment characters.
 160
 161	**Example**
 162
 163	```py
 164	x = [['A', 'B', 'C'], ['1', '1.9999', 'foo'], ['10', 'x', 'bar']]
 165	print(pretty_table(x))
 166	```
 167	yields:	
 168	```
 169	--  ------  ---
 170	A        B    C
 171	--  ------  ---
 172	1   1.9999  foo
 173	10       x  bar
 174	--  ------  ---
 175	```
 176	
 177	'''
 178	txt = []
 179	widths = [np.max([len(e) for e in c]) for c in zip(*x)]
 180
 181	if len(widths) > len(align):
 182		align += '>' * (len(widths)-len(align))
 183	sepline = hsep.join([vsep*w for w in widths])
 184	txt += [sepline]
 185	for k,l in enumerate(x):
 186		if k and k == header:
 187			txt += [sepline]
 188		txt += [hsep.join([f'{e:{a}{w}}' for e, w, a in zip(l, widths, align)])]
 189	txt += [sepline]
 190	txt += ['']
 191	return '\n'.join(txt)
 192
 193
 194def transpose_table(x):
 195	'''
 196	Transpose a list if lists
 197
 198	**Parameters**
 199
 200	+ `x`: a list of lists
 201
 202	**Example**
 203
 204	```py
 205	x = [[1, 2], [3, 4]]
 206	print(transpose_table(x)) # yields: [[1, 3], [2, 4]]
 207	```
 208	'''
 209	return [[e for e in c] for c in zip(*x)]
 210
 211
 212def w_avg(X, sX) :
 213	'''
 214	Compute variance-weighted average
 215
 216	Returns the value and SE of the weighted average of the elements of `X`,
 217	with relative weights equal to their inverse variances (`1/sX**2`).
 218
 219	**Parameters**
 220
 221	+ `X`: array-like of elements to average
 222	+ `sX`: array-like of the corresponding SE values
 223
 224	**Tip**
 225
 226	If `X` and `sX` are initially arranged as a list of `(x, sx)` doublets,
 227	they may be rearranged using `zip()`:
 228
 229	```python
 230	foo = [(0, 1), (1, 0.5), (2, 0.5)]
 231	print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333)
 232	```
 233	'''
 234	X = [ x for x in X ]
 235	sX = [ sx for sx in sX ]
 236	W = [ sx**-2 for sx in sX ]
 237	W = [ w/sum(W) for w in W ]
 238	Xavg = sum([ w*x for w,x in zip(W,X) ])
 239	sXavg = sum([ w**2*sx**2 for w,sx in zip(W,sX) ])**.5
 240	return Xavg, sXavg
 241
 242
 243def read_csv(filename, sep = ''):
 244	'''
 245	Read contents of `filename` in csv format and return a list of dictionaries.
 246
 247	In the csv string, spaces before and after field separators (`','` by default)
 248	are optional.
 249
 250	**Parameters**
 251
 252	+ `filename`: the csv file to read
 253	+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
 254	whichever appers most often in the contents of `filename`.
 255	'''
 256	with open(filename) as fid:
 257		txt = fid.read()
 258
 259	if sep == '':
 260		sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
 261	txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
 262	return [{k: smart_type(v) for k,v in zip(txt[0], l) if v} for l in txt[1:]]
 263
 264
 265def simulate_single_analysis(
 266	sample = 'MYSAMPLE',
 267	d13Cwg_VPDB = -4., d18Owg_VSMOW = 26.,
 268	d13C_VPDB = None, d18O_VPDB = None,
 269	D47 = None, D48 = None, D49 = 0., D17O = 0.,
 270	a47 = 1., b47 = 0., c47 = -0.9,
 271	a48 = 1., b48 = 0., c48 = -0.45,
 272	Nominal_D47 = None,
 273	Nominal_D48 = None,
 274	Nominal_d13C_VPDB = None,
 275	Nominal_d18O_VPDB = None,
 276	ALPHA_18O_ACID_REACTION = None,
 277	R13_VPDB = None,
 278	R17_VSMOW = None,
 279	R18_VSMOW = None,
 280	LAMBDA_17 = None,
 281	R18_VPDB = None,
 282	):
 283	'''
 284	Compute working-gas delta values for a single analysis, assuming a stochastic working
 285	gas and a “perfect” measurement (i.e. raw Δ values are identical to absolute values).
 286	
 287	**Parameters**
 288
 289	+ `sample`: sample name
 290	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
 291		(respectively –4 and +26 ‰ by default)
 292	+ `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
 293	+ `D47`, `D48`, `D49`, `D17O`: clumped-isotope and oxygen-17 anomalies
 294		of the carbonate sample
 295	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and
 296		Δ48 values if `D47` or `D48` are not specified
 297	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
 298		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
 299	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
 300	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
 301		correction parameters (by default equal to the `D4xdata` default values)
 302	
 303	Returns a dictionary with fields
 304	`['Sample', 'D17O', 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'd45', 'd46', 'd47', 'd48', 'd49']`.
 305	'''
 306
 307	if Nominal_d13C_VPDB is None:
 308		Nominal_d13C_VPDB = D4xdata().Nominal_d13C_VPDB
 309
 310	if Nominal_d18O_VPDB is None:
 311		Nominal_d18O_VPDB = D4xdata().Nominal_d18O_VPDB
 312
 313	if ALPHA_18O_ACID_REACTION is None:
 314		ALPHA_18O_ACID_REACTION = D4xdata().ALPHA_18O_ACID_REACTION
 315
 316	if R13_VPDB is None:
 317		R13_VPDB = D4xdata().R13_VPDB
 318
 319	if R17_VSMOW is None:
 320		R17_VSMOW = D4xdata().R17_VSMOW
 321
 322	if R18_VSMOW is None:
 323		R18_VSMOW = D4xdata().R18_VSMOW
 324
 325	if LAMBDA_17 is None:
 326		LAMBDA_17 = D4xdata().LAMBDA_17
 327
 328	if R18_VPDB is None:
 329		R18_VPDB = D4xdata().R18_VPDB
 330	
 331	R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW) ** LAMBDA_17
 332	
 333	if Nominal_D47 is None:
 334		Nominal_D47 = D47data().Nominal_D47
 335
 336	if Nominal_D48 is None:
 337		Nominal_D48 = D48data().Nominal_D48
 338	
 339	if d13C_VPDB is None:
 340		if sample in Nominal_d13C_VPDB:
 341			d13C_VPDB = Nominal_d13C_VPDB[sample]
 342		else:
 343			raise KeyError(f"Sample {sample} is missing d13C_VDP value, and it is not defined in Nominal_d13C_VDP.")
 344
 345	if d18O_VPDB is None:
 346		if sample in Nominal_d18O_VPDB:
 347			d18O_VPDB = Nominal_d18O_VPDB[sample]
 348		else:
 349			raise KeyError(f"Sample {sample} is missing d18O_VPDB value, and it is not defined in Nominal_d18O_VPDB.")
 350
 351	if D47 is None:
 352		if sample in Nominal_D47:
 353			D47 = Nominal_D47[sample]
 354		else:
 355			raise KeyError(f"Sample {sample} is missing D47 value, and it is not defined in Nominal_D47.")
 356
 357	if D48 is None:
 358		if sample in Nominal_D48:
 359			D48 = Nominal_D48[sample]
 360		else:
 361			raise KeyError(f"Sample {sample} is missing D48 value, and it is not defined in Nominal_D48.")
 362
 363	X = D4xdata()
 364	X.R13_VPDB = R13_VPDB
 365	X.R17_VSMOW = R17_VSMOW
 366	X.R18_VSMOW = R18_VSMOW
 367	X.LAMBDA_17 = LAMBDA_17
 368	X.R18_VPDB = R18_VPDB
 369	X.R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW)**LAMBDA_17
 370
 371	R45wg, R46wg, R47wg, R48wg, R49wg = X.compute_isobar_ratios(
 372		R13 = R13_VPDB * (1 + d13Cwg_VPDB/1000),
 373		R18 = R18_VSMOW * (1 + d18Owg_VSMOW/1000),
 374		)
 375	R45, R46, R47, R48, R49 = X.compute_isobar_ratios(
 376		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
 377		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
 378		D17O=D17O, D47=D47, D48=D48, D49=D49,
 379		)
 380	R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = X.compute_isobar_ratios(
 381		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
 382		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
 383		D17O=D17O,
 384		)
 385	
 386	d45 = 1000 * (R45/R45wg - 1)
 387	d46 = 1000 * (R46/R46wg - 1)
 388	d47 = 1000 * (R47/R47wg - 1)
 389	d48 = 1000 * (R48/R48wg - 1)
 390	d49 = 1000 * (R49/R49wg - 1)
 391
 392	for k in range(3): # dumb iteration to adjust for small changes in d47
 393		R47raw = (1 + (a47 * D47 + b47 * d47 + c47)/1000) * R47stoch
 394		R48raw = (1 + (a48 * D48 + b48 * d48 + c48)/1000) * R48stoch	
 395		d47 = 1000 * (R47raw/R47wg - 1)
 396		d48 = 1000 * (R48raw/R48wg - 1)
 397
 398	return dict(
 399		Sample = sample,
 400		D17O = D17O,
 401		d13Cwg_VPDB = d13Cwg_VPDB,
 402		d18Owg_VSMOW = d18Owg_VSMOW,
 403		d45 = d45,
 404		d46 = d46,
 405		d47 = d47,
 406		d48 = d48,
 407		d49 = d49,
 408		)
 409
 410
 411def virtual_data(
 412	samples = [],
 413	a47 = 1., b47 = 0., c47 = -0.9,
 414	a48 = 1., b48 = 0., c48 = -0.45,
 415	rD47 = 0.015, rD48 = 0.045,
 416	d13Cwg_VPDB = None, d18Owg_VSMOW = None,
 417	session = None,
 418	Nominal_D47 = None, Nominal_D48 = None,
 419	Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None,
 420	ALPHA_18O_ACID_REACTION = None,
 421	R13_VPDB = None,
 422	R17_VSMOW = None,
 423	R18_VSMOW = None,
 424	LAMBDA_17 = None,
 425	R18_VPDB = None,
 426	seed = 0,
 427	):
 428	'''
 429	Return list with simulated analyses from a single session.
 430	
 431	**Parameters**
 432	
 433	+ `samples`: a list of entries; each entry is a dictionary with the following fields:
 434	    * `Sample`: the name of the sample
 435	    * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
 436	    * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
 437	    * `N`: how many analyses to generate for this sample
 438	+ `a47`: scrambling factor for Δ47
 439	+ `b47`: compositional nonlinearity for Δ47
 440	+ `c47`: working gas offset for Δ47
 441	+ `a48`: scrambling factor for Δ48
 442	+ `b48`: compositional nonlinearity for Δ48
 443	+ `c48`: working gas offset for Δ48
 444	+ `rD47`: analytical repeatability of Δ47
 445	+ `rD48`: analytical repeatability of Δ48
 446	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
 447		(by default equal to the `simulate_single_analysis` default values)
 448	+ `session`: name of the session (no name by default)
 449	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values
 450		if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
 451	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
 452		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified 
 453		(by default equal to the `simulate_single_analysis` defaults)
 454	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
 455		(by default equal to the `simulate_single_analysis` defaults)
 456	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
 457		correction parameters (by default equal to the `simulate_single_analysis` default)
 458	+ `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations
 459	
 460		
 461	Here is an example of using this method to generate an arbitrary combination of
 462	anchors and unknowns for a bunch of sessions:
 463
 464	```py
 465	args = dict(
 466		samples = [
 467			dict(Sample = 'ETH-1', N = 4),
 468			dict(Sample = 'ETH-2', N = 5),
 469			dict(Sample = 'ETH-3', N = 6),
 470			dict(Sample = 'FOO', N = 2,
 471				d13C_VPDB = -5., d18O_VPDB = -10.,
 472				D47 = 0.3, D48 = 0.15),
 473			], rD47 = 0.010, rD48 = 0.030)
 474
 475	session1 = virtual_data(session = 'Session_01', **args, seed = 123)
 476	session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
 477	session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
 478	session4 = virtual_data(session = 'Session_04', **args, seed = 123456)
 479
 480	D = D47data(session1 + session2 + session3 + session4)
 481
 482	D.crunch()
 483	D.standardize()
 484
 485	D.table_of_sessions(verbose = True, save_to_file = False)
 486	D.table_of_samples(verbose = True, save_to_file = False)
 487	D.table_of_analyses(verbose = True, save_to_file = False)
 488	```
 489	
 490	This should output something like:
 491	
 492	```
 493	[table_of_sessions] 
 494	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
 495	Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47         a ± SE    1e3 x b ± SE          c ± SE
 496	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
 497	Session_01  15   2       -4.000        26.000  0.0000  0.0000  0.0110  0.997 ± 0.017  -0.097 ± 0.244  -0.896 ± 0.006
 498	Session_02  15   2       -4.000        26.000  0.0000  0.0000  0.0109  1.002 ± 0.017  -0.110 ± 0.244  -0.901 ± 0.006
 499	Session_03  15   2       -4.000        26.000  0.0000  0.0000  0.0107  1.010 ± 0.017  -0.037 ± 0.244  -0.904 ± 0.006
 500	Session_04  15   2       -4.000        26.000  0.0000  0.0000  0.0106  1.001 ± 0.017  -0.181 ± 0.244  -0.894 ± 0.006
 501	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
 502
 503	[table_of_samples] 
 504	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
 505	Sample   N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
 506	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
 507	ETH-1   16       2.02       37.02  0.2052                    0.0079          
 508	ETH-2   20     -10.17       19.88  0.2085                    0.0100          
 509	ETH-3   24       1.71       37.45  0.6132                    0.0105          
 510	FOO      8      -5.00       28.91  0.2989  0.0040  ± 0.0080  0.0101     0.638
 511	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
 512
 513	[table_of_analyses] 
 514	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
 515	UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
 516	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
 517	1    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.122986   21.273526   27.780042    2.020000   37.024281  -0.706013  -0.328878  -0.000013  0.192554
 518	2    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.130144   21.282615   27.780042    2.020000   37.024281  -0.698974  -0.319981  -0.000013  0.199615
 519	3    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.149219   21.299572   27.780042    2.020000   37.024281  -0.680215  -0.303383  -0.000013  0.218429
 520	4    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.136616   21.233128   27.780042    2.020000   37.024281  -0.692609  -0.368421  -0.000013  0.205998
 521	5    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.697171  -12.203054  -18.023381  -10.170000   19.875825  -0.680771  -0.290128  -0.000002  0.215054
 522	6    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701124  -12.184422  -18.023381  -10.170000   19.875825  -0.684772  -0.271272  -0.000002  0.211041
 523	7    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715105  -12.195251  -18.023381  -10.170000   19.875825  -0.698923  -0.282232  -0.000002  0.196848
 524	8    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701529  -12.204963  -18.023381  -10.170000   19.875825  -0.685182  -0.292061  -0.000002  0.210630
 525	9    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.711420  -12.228478  -18.023381  -10.170000   19.875825  -0.695193  -0.315859  -0.000002  0.200589
 526	10   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.666719   22.296486   28.306614    1.710000   37.450394  -0.290459  -0.147284  -0.000014  0.609363
 527	11   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.671553   22.291060   28.306614    1.710000   37.450394  -0.285706  -0.152592  -0.000014  0.614130
 528	12   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.652854   22.273271   28.306614    1.710000   37.450394  -0.304093  -0.169990  -0.000014  0.595689
 529	13   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.684168   22.263156   28.306614    1.710000   37.450394  -0.273302  -0.179883  -0.000014  0.626572
 530	14   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.662702   22.253578   28.306614    1.710000   37.450394  -0.294409  -0.189251  -0.000014  0.605401
 531	15   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.681957   22.230907   28.306614    1.710000   37.450394  -0.275476  -0.211424  -0.000014  0.624391
 532	16   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.312044    5.395798    4.665655   -5.000000   28.907344  -0.598436  -0.268176  -0.000006  0.298996
 533	17   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.328123    5.307086    4.665655   -5.000000   28.907344  -0.582387  -0.356389  -0.000006  0.315092
 534	18   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.122201   21.340606   27.780042    2.020000   37.024281  -0.706785  -0.263217  -0.000013  0.195135
 535	19   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.134868   21.305714   27.780042    2.020000   37.024281  -0.694328  -0.297370  -0.000013  0.207564
 536	20   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.140008   21.261931   27.780042    2.020000   37.024281  -0.689273  -0.340227  -0.000013  0.212607
 537	21   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.135540   21.298472   27.780042    2.020000   37.024281  -0.693667  -0.304459  -0.000013  0.208224
 538	22   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701213  -12.202602  -18.023381  -10.170000   19.875825  -0.684862  -0.289671  -0.000002  0.213842
 539	23   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.685649  -12.190405  -18.023381  -10.170000   19.875825  -0.669108  -0.277327  -0.000002  0.229559
 540	24   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.719003  -12.257955  -18.023381  -10.170000   19.875825  -0.702869  -0.345692  -0.000002  0.195876
 541	25   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700592  -12.204641  -18.023381  -10.170000   19.875825  -0.684233  -0.291735  -0.000002  0.214469
 542	26   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720426  -12.214561  -18.023381  -10.170000   19.875825  -0.704308  -0.301774  -0.000002  0.194439
 543	27   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.673044   22.262090   28.306614    1.710000   37.450394  -0.284240  -0.180926  -0.000014  0.616730
 544	28   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.666542   22.263401   28.306614    1.710000   37.450394  -0.290634  -0.179643  -0.000014  0.610350
 545	29   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.680487   22.243486   28.306614    1.710000   37.450394  -0.276921  -0.199121  -0.000014  0.624031
 546	30   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.663900   22.245175   28.306614    1.710000   37.450394  -0.293231  -0.197469  -0.000014  0.607759
 547	31   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.674379   22.301309   28.306614    1.710000   37.450394  -0.282927  -0.142568  -0.000014  0.618039
 548	32   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.660825   22.270466   28.306614    1.710000   37.450394  -0.296255  -0.172733  -0.000014  0.604742
 549	33   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.294076    5.349940    4.665655   -5.000000   28.907344  -0.616369  -0.313776  -0.000006  0.283707
 550	34   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.313775    5.292121    4.665655   -5.000000   28.907344  -0.596708  -0.371269  -0.000006  0.303323
 551	35   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.121613   21.259909   27.780042    2.020000   37.024281  -0.707364  -0.342207  -0.000013  0.194934
 552	36   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.145714   21.304889   27.780042    2.020000   37.024281  -0.683661  -0.298178  -0.000013  0.218401
 553	37   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.126573   21.325093   27.780042    2.020000   37.024281  -0.702485  -0.278401  -0.000013  0.199764
 554	38   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.132057   21.323211   27.780042    2.020000   37.024281  -0.697092  -0.280244  -0.000013  0.205104
 555	39   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.708448  -12.232023  -18.023381  -10.170000   19.875825  -0.692185  -0.319447  -0.000002  0.208915
 556	40   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.714417  -12.202504  -18.023381  -10.170000   19.875825  -0.698226  -0.289572  -0.000002  0.202934
 557	41   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720039  -12.264469  -18.023381  -10.170000   19.875825  -0.703917  -0.352285  -0.000002  0.197300
 558	42   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701953  -12.228550  -18.023381  -10.170000   19.875825  -0.685611  -0.315932  -0.000002  0.215423
 559	43   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.704535  -12.213634  -18.023381  -10.170000   19.875825  -0.688224  -0.300836  -0.000002  0.212837
 560	44   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.652920   22.230043   28.306614    1.710000   37.450394  -0.304028  -0.212269  -0.000014  0.594265
 561	45   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.691485   22.261017   28.306614    1.710000   37.450394  -0.266106  -0.181975  -0.000014  0.631810
 562	46   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.679119   22.305357   28.306614    1.710000   37.450394  -0.278266  -0.138609  -0.000014  0.619771
 563	47   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.663623   22.327286   28.306614    1.710000   37.450394  -0.293503  -0.117161  -0.000014  0.604685
 564	48   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.678524   22.282103   28.306614    1.710000   37.450394  -0.278851  -0.161352  -0.000014  0.619192
 565	49   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.666246   22.283361   28.306614    1.710000   37.450394  -0.290925  -0.160121  -0.000014  0.607238
 566	50   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.309929    5.340249    4.665655   -5.000000   28.907344  -0.600546  -0.323413  -0.000006  0.300148
 567	51   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.317548    5.334102    4.665655   -5.000000   28.907344  -0.592942  -0.329524  -0.000006  0.307676
 568	52   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.136865   21.300298   27.780042    2.020000   37.024281  -0.692364  -0.302672  -0.000013  0.204033
 569	53   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.133538   21.291260   27.780042    2.020000   37.024281  -0.695637  -0.311519  -0.000013  0.200762
 570	54   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.139991   21.319865   27.780042    2.020000   37.024281  -0.689290  -0.283519  -0.000013  0.207107
 571	55   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.145748   21.330075   27.780042    2.020000   37.024281  -0.683629  -0.273524  -0.000013  0.212766
 572	56   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702989  -12.202762  -18.023381  -10.170000   19.875825  -0.686660  -0.289833  -0.000002  0.204507
 573	57   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.692830  -12.240287  -18.023381  -10.170000   19.875825  -0.676377  -0.327811  -0.000002  0.214786
 574	58   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702899  -12.180291  -18.023381  -10.170000   19.875825  -0.686568  -0.267091  -0.000002  0.204598
 575	59   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.709282  -12.282257  -18.023381  -10.170000   19.875825  -0.693029  -0.370287  -0.000002  0.198140
 576	60   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.679330  -12.235994  -18.023381  -10.170000   19.875825  -0.662712  -0.323466  -0.000002  0.228446
 577	61   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.695594   22.238663   28.306614    1.710000   37.450394  -0.262066  -0.203838  -0.000014  0.634200
 578	62   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.663504   22.286354   28.306614    1.710000   37.450394  -0.293620  -0.157194  -0.000014  0.602656
 579	63   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666457   22.254290   28.306614    1.710000   37.450394  -0.290717  -0.188555  -0.000014  0.605558
 580	64   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666910   22.223232   28.306614    1.710000   37.450394  -0.290271  -0.218930  -0.000014  0.606004
 581	65   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.679662   22.257256   28.306614    1.710000   37.450394  -0.277732  -0.185653  -0.000014  0.618539
 582	66   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.676768   22.267680   28.306614    1.710000   37.450394  -0.280578  -0.175459  -0.000014  0.615693
 583	67   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.307663    5.317330    4.665655   -5.000000   28.907344  -0.602808  -0.346202  -0.000006  0.290853
 584	68   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.308562    5.331400    4.665655   -5.000000   28.907344  -0.601911  -0.332212  -0.000006  0.291749
 585	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
 586	```
 587	'''
 588	
 589	kwargs = locals().copy()
 590
 591	from numpy import random as nprandom
 592	if seed:
 593		rng = nprandom.default_rng(seed)
 594	else:
 595		rng = nprandom.default_rng()
 596	
 597	N = sum([s['N'] for s in samples])
 598	errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
 599	errors47 *= rD47 / stdev(errors47) # scale errors to rD47
 600	errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
 601	errors48 *= rD48 / stdev(errors48) # scale errors to rD48
 602	
 603	k = 0
 604	out = []
 605	for s in samples:
 606		kw = {}
 607		kw['sample'] = s['Sample']
 608		kw = {
 609			**kw,
 610			**{var: kwargs[var]
 611				for var in [
 612					'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION',
 613					'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB',
 614					'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB',
 615					'a47', 'b47', 'c47', 'a48', 'b48', 'c48',
 616					]
 617				if kwargs[var] is not None},
 618			**{var: s[var]
 619				for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O']
 620				if var in s},
 621			}
 622
 623		sN = s['N']
 624		while sN:
 625			out.append(simulate_single_analysis(**kw))
 626			out[-1]['d47'] += errors47[k] * a47
 627			out[-1]['d48'] += errors48[k] * a48
 628			sN -= 1
 629			k += 1
 630
 631		if session is not None:
 632			for r in out:
 633				r['Session'] = session
 634	return out
 635
 636def table_of_samples(
 637	data47 = None,
 638	data48 = None,
 639	dir = 'output',
 640	filename = None,
 641	save_to_file = True,
 642	print_out = True,
 643	output = None,
 644	):
 645	'''
 646	Print out, save to disk and/or return a combined table of samples
 647	for a pair of `D47data` and `D48data` objects.
 648
 649	**Parameters**
 650
 651	+ `data47`: `D47data` instance
 652	+ `data48`: `D48data` instance
 653	+ `dir`: the directory in which to save the table
 654	+ `filename`: the name to the csv file to write to
 655	+ `save_to_file`: whether to save the table to disk
 656	+ `print_out`: whether to print out the table
 657	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
 658		if set to `'raw'`: return a list of list of strings
 659		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
 660	'''
 661	if data47 is None:
 662		if data48 is None:
 663			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
 664		else:
 665			return data48.table_of_samples(
 666				dir = dir,
 667				filename = filename,
 668				save_to_file = save_to_file,
 669				print_out = print_out,
 670				output = output
 671				)
 672	else:
 673		if data48 is None:
 674			return data47.table_of_samples(
 675				dir = dir,
 676				filename = filename,
 677				save_to_file = save_to_file,
 678				print_out = print_out,
 679				output = output
 680				)
 681		else:
 682			out47 = data47.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
 683			out48 = data48.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
 684			out = transpose_table(transpose_table(out47) + transpose_table(out48)[4:])
 685
 686			if save_to_file:
 687				if not os.path.exists(dir):
 688					os.makedirs(dir)
 689				if filename is None:
 690					filename = f'D47D48_samples.csv'
 691				with open(f'{dir}/{filename}', 'w') as fid:
 692					fid.write(make_csv(out))
 693			if print_out:
 694				print('\n'+pretty_table(out))
 695			if output == 'raw':
 696				return out
 697			elif output == 'pretty':
 698				return pretty_table(out)
 699
 700
 701def table_of_sessions(
 702	data47 = None,
 703	data48 = None,
 704	dir = 'output',
 705	filename = None,
 706	save_to_file = True,
 707	print_out = True,
 708	output = None,
 709	):
 710	'''
 711	Print out, save to disk and/or return a combined table of sessions
 712	for a pair of `D47data` and `D48data` objects.
 713	***Only applicable if the sessions in `data47` and those in `data48`
 714	consist of the exact same sets of analyses.***
 715
 716	**Parameters**
 717
 718	+ `data47`: `D47data` instance
 719	+ `data48`: `D48data` instance
 720	+ `dir`: the directory in which to save the table
 721	+ `filename`: the name to the csv file to write to
 722	+ `save_to_file`: whether to save the table to disk
 723	+ `print_out`: whether to print out the table
 724	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
 725		if set to `'raw'`: return a list of list of strings
 726		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
 727	'''
 728	if data47 is None:
 729		if data48 is None:
 730			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
 731		else:
 732			return data48.table_of_sessions(
 733				dir = dir,
 734				filename = filename,
 735				save_to_file = save_to_file,
 736				print_out = print_out,
 737				output = output
 738				)
 739	else:
 740		if data48 is None:
 741			return data47.table_of_sessions(
 742				dir = dir,
 743				filename = filename,
 744				save_to_file = save_to_file,
 745				print_out = print_out,
 746				output = output
 747				)
 748		else:
 749			out47 = data47.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
 750			out48 = data48.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
 751			for k,x in enumerate(out47[0]):
 752				if k>7:
 753					out47[0][k] = out47[0][k].replace('a', 'a_47').replace('b', 'b_47').replace('c', 'c_47')
 754					out48[0][k] = out48[0][k].replace('a', 'a_48').replace('b', 'b_48').replace('c', 'c_48')
 755			out = transpose_table(transpose_table(out47) + transpose_table(out48)[7:])
 756
 757			if save_to_file:
 758				if not os.path.exists(dir):
 759					os.makedirs(dir)
 760				if filename is None:
 761					filename = f'D47D48_sessions.csv'
 762				with open(f'{dir}/{filename}', 'w') as fid:
 763					fid.write(make_csv(out))
 764			if print_out:
 765				print('\n'+pretty_table(out))
 766			if output == 'raw':
 767				return out
 768			elif output == 'pretty':
 769				return pretty_table(out)
 770
 771
 772def table_of_analyses(
 773	data47 = None,
 774	data48 = None,
 775	dir = 'output',
 776	filename = None,
 777	save_to_file = True,
 778	print_out = True,
 779	output = None,
 780	):
 781	'''
 782	Print out, save to disk and/or return a combined table of analyses
 783	for a pair of `D47data` and `D48data` objects.
 784
 785	If the sessions in `data47` and those in `data48` do not consist of
 786	the exact same sets of analyses, the table will have two columns
 787	`Session_47` and `Session_48` instead of a single `Session` column.
 788
 789	**Parameters**
 790
 791	+ `data47`: `D47data` instance
 792	+ `data48`: `D48data` instance
 793	+ `dir`: the directory in which to save the table
 794	+ `filename`: the name to the csv file to write to
 795	+ `save_to_file`: whether to save the table to disk
 796	+ `print_out`: whether to print out the table
 797	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
 798		if set to `'raw'`: return a list of list of strings
 799		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
 800	'''
 801	if data47 is None:
 802		if data48 is None:
 803			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
 804		else:
 805			return data48.table_of_analyses(
 806				dir = dir,
 807				filename = filename,
 808				save_to_file = save_to_file,
 809				print_out = print_out,
 810				output = output
 811				)
 812	else:
 813		if data48 is None:
 814			return data47.table_of_analyses(
 815				dir = dir,
 816				filename = filename,
 817				save_to_file = save_to_file,
 818				print_out = print_out,
 819				output = output
 820				)
 821		else:
 822			out47 = data47.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
 823			out48 = data48.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
 824			
 825			if [l[1] for l in out47[1:]] == [l[1] for l in out48[1:]]: # if sessions are identical
 826				out = transpose_table(transpose_table(out47) + transpose_table(out48)[-1:])
 827			else:
 828				out47[0][1] = 'Session_47'
 829				out48[0][1] = 'Session_48'
 830				out47 = transpose_table(out47)
 831				out48 = transpose_table(out48)
 832				out = transpose_table(out47[:2] + out48[1:2] + out47[2:] + out48[-1:])
 833
 834			if save_to_file:
 835				if not os.path.exists(dir):
 836					os.makedirs(dir)
 837				if filename is None:
 838					filename = f'D47D48_sessions.csv'
 839				with open(f'{dir}/{filename}', 'w') as fid:
 840					fid.write(make_csv(out))
 841			if print_out:
 842				print('\n'+pretty_table(out))
 843			if output == 'raw':
 844				return out
 845			elif output == 'pretty':
 846				return pretty_table(out)
 847
 848
 849class D4xdata(list):
 850	'''
 851	Store and process data for a large set of Δ47 and/or Δ48
 852	analyses, usually comprising more than one analytical session.
 853	'''
 854
 855	### 17O CORRECTION PARAMETERS
 856	R13_VPDB = 0.01118  # (Chang & Li, 1990)
 857	'''
 858	Absolute (13C/12C) ratio of VPDB.
 859	By default equal to 0.01118 ([Chang & Li, 1990](http://www.cnki.com.cn/Article/CJFDTotal-JXTW199004006.htm))
 860	'''
 861
 862	R18_VSMOW = 0.0020052  # (Baertschi, 1976)
 863	'''
 864	Absolute (18O/16C) ratio of VSMOW.
 865	By default equal to 0.0020052 ([Baertschi, 1976](https://doi.org/10.1016/0012-821X(76)90115-1))
 866	'''
 867
 868	LAMBDA_17 = 0.528  # (Barkan & Luz, 2005)
 869	'''
 870	Mass-dependent exponent for triple oxygen isotopes.
 871	By default equal to 0.528 ([Barkan & Luz, 2005](https://doi.org/10.1002/rcm.2250))
 872	'''
 873
 874	R17_VSMOW = 0.00038475  # (Assonov & Brenninkmeijer, 2003, rescaled to R13_VPDB)
 875	'''
 876	Absolute (17O/16C) ratio of VSMOW.
 877	By default equal to 0.00038475
 878	([Assonov & Brenninkmeijer, 2003](https://dx.doi.org/10.1002/rcm.1011),
 879	rescaled to `R13_VPDB`)
 880	'''
 881
 882	R18_VPDB = R18_VSMOW * 1.03092
 883	'''
 884	Absolute (18O/16C) ratio of VPDB.
 885	By definition equal to `R18_VSMOW * 1.03092`.
 886	'''
 887
 888	R17_VPDB = R17_VSMOW * 1.03092 ** LAMBDA_17
 889	'''
 890	Absolute (17O/16C) ratio of VPDB.
 891	By definition equal to `R17_VSMOW * 1.03092 ** LAMBDA_17`.
 892	'''
 893
 894	LEVENE_REF_SAMPLE = 'ETH-3'
 895	'''
 896	After the Δ4x standardization step, each sample is tested to
 897	assess whether the Δ4x variance within all analyses for that
 898	sample differs significantly from that observed for a given reference
 899	sample (using [Levene's test](https://en.wikipedia.org/wiki/Levene%27s_test),
 900	which yields a p-value corresponding to the null hypothesis that the
 901	underlying variances are equal).
 902
 903	`LEVENE_REF_SAMPLE` (by default equal to `'ETH-3'`) specifies which
 904	sample should be used as a reference for this test.
 905	'''
 906
 907	ALPHA_18O_ACID_REACTION = round(np.exp(3.59 / (90 + 273.15) - 1.79e-3), 6)  # (Kim et al., 2007, calcite)
 908	'''
 909	Specifies the 18O/16O fractionation factor generally applicable
 910	to acid reactions in the dataset. Currently used by `D4xdata.wg()`,
 911	`D4xdata.standardize_d13C`, and `D4xdata.standardize_d18O`.
 912
 913	By default equal to 1.008129 (calcite reacted at 90 °C,
 914	[Kim et al., 2007](https://dx.doi.org/10.1016/j.chemgeo.2007.08.005)).
 915	'''
 916
 917	Nominal_d13C_VPDB = {
 918		'ETH-1': 2.02,
 919		'ETH-2': -10.17,
 920		'ETH-3': 1.71,
 921		}	# (Bernasconi et al., 2018)
 922	'''
 923	Nominal δ13C_VPDB values assigned to carbonate standards, used by
 924	`D4xdata.standardize_d13C()`.
 925
 926	By default equal to `{'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71}` after
 927	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
 928	'''
 929
 930	Nominal_d18O_VPDB = {
 931		'ETH-1': -2.19,
 932		'ETH-2': -18.69,
 933		'ETH-3': -1.78,
 934		}	# (Bernasconi et al., 2018)
 935	'''
 936	Nominal δ18O_VPDB values assigned to carbonate standards, used by
 937	`D4xdata.standardize_d18O()`.
 938
 939	By default equal to `{'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78}` after
 940	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
 941	'''
 942
 943	d13C_STANDARDIZATION_METHOD = '2pt'
 944	'''
 945	Method by which to standardize δ13C values:
 946	
 947	+ `none`: do not apply any δ13C standardization.
 948	+ `'1pt'`: within each session, offset all initial δ13C values so as to
 949	minimize the difference between final δ13C_VPDB values and
 950	`Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB` is defined).
 951	+ `'2pt'`: within each session, apply a affine trasformation to all δ13C
 952	values so as to minimize the difference between final δ13C_VPDB
 953	values and `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB`
 954	is defined).
 955	'''
 956
 957	d18O_STANDARDIZATION_METHOD = '2pt'
 958	'''
 959	Method by which to standardize δ18O values:
 960	
 961	+ `none`: do not apply any δ18O standardization.
 962	+ `'1pt'`: within each session, offset all initial δ18O values so as to
 963	minimize the difference between final δ18O_VPDB values and
 964	`Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` is defined).
 965	+ `'2pt'`: within each session, apply a affine trasformation to all δ18O
 966	values so as to minimize the difference between final δ18O_VPDB
 967	values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB`
 968	is defined).
 969	'''
 970
 971	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
 972		'''
 973		**Parameters**
 974
 975		+ `l`: a list of dictionaries, with each dictionary including at least the keys
 976		`Sample`, `d45`, `d46`, and `d47` or `d48`.
 977		+ `mass`: `'47'` or `'48'`
 978		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
 979		+ `session`: define session name for analyses without a `Session` key
 980		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.
 981
 982		Returns a `D4xdata` object derived from `list`.
 983		'''
 984		self._4x = mass
 985		self.verbose = verbose
 986		self.prefix = 'D4xdata'
 987		self.logfile = logfile
 988		list.__init__(self, l)
 989		self.Nf = None
 990		self.repeatability = {}
 991		self.refresh(session = session)
 992
 993
 994	def make_verbal(oldfun):
 995		'''
 996		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
 997		'''
 998		@wraps(oldfun)
 999		def newfun(*args, verbose = '', **kwargs):
1000			myself = args[0]
1001			oldprefix = myself.prefix
1002			myself.prefix = oldfun.__name__
1003			if verbose != '':
1004				oldverbose = myself.verbose
1005				myself.verbose = verbose
1006			out = oldfun(*args, **kwargs)
1007			myself.prefix = oldprefix
1008			if verbose != '':
1009				myself.verbose = oldverbose
1010			return out
1011		return newfun
1012
1013
1014	def msg(self, txt):
1015		'''
1016		Log a message to `self.logfile`, and print it out if `verbose = True`
1017		'''
1018		self.log(txt)
1019		if self.verbose:
1020			print(f'{f"[{self.prefix}]":<16} {txt}')
1021
1022
1023	def vmsg(self, txt):
1024		'''
1025		Log a message to `self.logfile` and print it out
1026		'''
1027		self.log(txt)
1028		print(txt)
1029
1030
1031	def log(self, *txts):
1032		'''
1033		Log a message to `self.logfile`
1034		'''
1035		if self.logfile:
1036			with open(self.logfile, 'a') as fid:
1037				for txt in txts:
1038					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
1039
1040
1041	def refresh(self, session = 'mySession'):
1042		'''
1043		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
1044		'''
1045		self.fill_in_missing_info(session = session)
1046		self.refresh_sessions()
1047		self.refresh_samples()
1048
1049
1050	def refresh_sessions(self):
1051		'''
1052		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
1053		to `False` for all sessions.
1054		'''
1055		self.sessions = {
1056			s: {'data': [r for r in self if r['Session'] == s]}
1057			for s in sorted({r['Session'] for r in self})
1058			}
1059		for s in self.sessions:
1060			self.sessions[s]['scrambling_drift'] = False
1061			self.sessions[s]['slope_drift'] = False
1062			self.sessions[s]['wg_drift'] = False
1063			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
1064			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
1065
1066
1067	def refresh_samples(self):
1068		'''
1069		Define `self.samples`, `self.anchors`, and `self.unknowns`.
1070		'''
1071		self.samples = {
1072			s: {'data': [r for r in self if r['Sample'] == s]}
1073			for s in sorted({r['Sample'] for r in self})
1074			}
1075		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
1076		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
1077
1078
1079	def read(self, filename, sep = '', session = ''):
1080		'''
1081		Read file in csv format to load data into a `D47data` object.
1082
1083		In the csv file, spaces before and after field separators (`','` by default)
1084		are optional. Each line corresponds to a single analysis.
1085
1086		The required fields are:
1087
1088		+ `UID`: a unique identifier
1089		+ `Session`: an identifier for the analytical session
1090		+ `Sample`: a sample identifier
1091		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
1092
1093		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
1094		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
1095		and `d49` are optional, and set to NaN by default.
1096
1097		**Parameters**
1098
1099		+ `fileneme`: the path of the file to read
1100		+ `sep`: csv separator delimiting the fields
1101		+ `session`: set `Session` field to this string for all analyses
1102		'''
1103		with open(filename) as fid:
1104			self.input(fid.read(), sep = sep, session = session)
1105
1106
1107	def input(self, txt, sep = '', session = ''):
1108		'''
1109		Read `txt` string in csv format to load analysis data into a `D47data` object.
1110
1111		In the csv string, spaces before and after field separators (`','` by default)
1112		are optional. Each line corresponds to a single analysis.
1113
1114		The required fields are:
1115
1116		+ `UID`: a unique identifier
1117		+ `Session`: an identifier for the analytical session
1118		+ `Sample`: a sample identifier
1119		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
1120
1121		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
1122		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
1123		and `d49` are optional, and set to NaN by default.
1124
1125		**Parameters**
1126
1127		+ `txt`: the csv string to read
1128		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
1129		whichever appers most often in `txt`.
1130		+ `session`: set `Session` field to this string for all analyses
1131		'''
1132		if sep == '':
1133			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
1134		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
1135		data = [{k: v if k in ['UID', 'Session', 'Sample'] else smart_type(v) for k,v in zip(txt[0], l) if v != ''} for l in txt[1:]]
1136
1137		if session != '':
1138			for r in data:
1139				r['Session'] = session
1140
1141		self += data
1142		self.refresh()
1143
1144
1145	@make_verbal
1146	def wg(self, samples = None, a18_acid = None):
1147		'''
1148		Compute bulk composition of the working gas for each session based on
1149		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
1150		`self.Nominal_d18O_VPDB`.
1151		'''
1152
1153		self.msg('Computing WG composition:')
1154
1155		if a18_acid is None:
1156			a18_acid = self.ALPHA_18O_ACID_REACTION
1157		if samples is None:
1158			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
1159
1160		assert a18_acid, f'Acid fractionation factor should not be zero.'
1161
1162		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
1163		R45R46_standards = {}
1164		for sample in samples:
1165			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
1166			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
1167			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
1168			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
1169			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
1170
1171			C12_s = 1 / (1 + R13_s)
1172			C13_s = R13_s / (1 + R13_s)
1173			C16_s = 1 / (1 + R17_s + R18_s)
1174			C17_s = R17_s / (1 + R17_s + R18_s)
1175			C18_s = R18_s / (1 + R17_s + R18_s)
1176
1177			C626_s = C12_s * C16_s ** 2
1178			C627_s = 2 * C12_s * C16_s * C17_s
1179			C628_s = 2 * C12_s * C16_s * C18_s
1180			C636_s = C13_s * C16_s ** 2
1181			C637_s = 2 * C13_s * C16_s * C17_s
1182			C727_s = C12_s * C17_s ** 2
1183
1184			R45_s = (C627_s + C636_s) / C626_s
1185			R46_s = (C628_s + C637_s + C727_s) / C626_s
1186			R45R46_standards[sample] = (R45_s, R46_s)
1187		
1188		for s in self.sessions:
1189			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
1190			assert db, f'No sample from {samples} found in session "{s}".'
1191# 			dbsamples = sorted({r['Sample'] for r in db})
1192
1193			X = [r['d45'] for r in db]
1194			Y = [R45R46_standards[r['Sample']][0] for r in db]
1195			x1, x2 = np.min(X), np.max(X)
1196
1197			if x1 < x2:
1198				wgcoord = x1/(x1-x2)
1199			else:
1200				wgcoord = 999
1201
1202			if wgcoord < -.5 or wgcoord > 1.5:
1203				# unreasonable to extrapolate to d45 = 0
1204				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
1205			else :
1206				# d45 = 0 is reasonably well bracketed
1207				R45_wg = np.polyfit(X, Y, 1)[1]
1208
1209			X = [r['d46'] for r in db]
1210			Y = [R45R46_standards[r['Sample']][1] for r in db]
1211			x1, x2 = np.min(X), np.max(X)
1212
1213			if x1 < x2:
1214				wgcoord = x1/(x1-x2)
1215			else:
1216				wgcoord = 999
1217
1218			if wgcoord < -.5 or wgcoord > 1.5:
1219				# unreasonable to extrapolate to d46 = 0
1220				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
1221			else :
1222				# d46 = 0 is reasonably well bracketed
1223				R46_wg = np.polyfit(X, Y, 1)[1]
1224
1225			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
1226
1227			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
1228
1229			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
1230			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
1231			for r in self.sessions[s]['data']:
1232				r['d13Cwg_VPDB'] = d13Cwg_VPDB
1233				r['d18Owg_VSMOW'] = d18Owg_VSMOW
1234
1235
1236	def compute_bulk_delta(self, R45, R46, D17O = 0):
1237		'''
1238		Compute δ13C_VPDB and δ18O_VSMOW,
1239		by solving the generalized form of equation (17) from
1240		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
1241		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
1242		solving the corresponding second-order Taylor polynomial.
1243		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
1244		'''
1245
1246		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
1247
1248		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
1249		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
1250		C = 2 * self.R18_VSMOW
1251		D = -R46
1252
1253		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
1254		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
1255		cc = A + B + C + D
1256
1257		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
1258
1259		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
1260		R17 = K * R18 ** self.LAMBDA_17
1261		R13 = R45 - 2 * R17
1262
1263		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
1264
1265		return d13C_VPDB, d18O_VSMOW
1266
1267
1268	@make_verbal
1269	def crunch(self, verbose = ''):
1270		'''
1271		Compute bulk composition and raw clumped isotope anomalies for all analyses.
1272		'''
1273		for r in self:
1274			self.compute_bulk_and_clumping_deltas(r)
1275		self.standardize_d13C()
1276		self.standardize_d18O()
1277		self.msg(f"Crunched {len(self)} analyses.")
1278
1279
1280	def fill_in_missing_info(self, session = 'mySession'):
1281		'''
1282		Fill in optional fields with default values
1283		'''
1284		for i,r in enumerate(self):
1285			if 'D17O' not in r:
1286				r['D17O'] = 0.
1287			if 'UID' not in r:
1288				r['UID'] = f'{i+1}'
1289			if 'Session' not in r:
1290				r['Session'] = session
1291			for k in ['d47', 'd48', 'd49']:
1292				if k not in r:
1293					r[k] = np.nan
1294
1295
1296	def standardize_d13C(self):
1297		'''
1298		Perform δ13C standadization within each session `s` according to
1299		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
1300		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
1301		may be redefined abitrarily at a later stage.
1302		'''
1303		for s in self.sessions:
1304			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
1305				XY = [(r['d13C_VPDB'], self.Nominal_d13C_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d13C_VPDB]
1306				X,Y = zip(*XY)
1307				if self.sessions[s]['d13C_standardization_method'] == '1pt':
1308					offset = np.mean(Y) - np.mean(X)
1309					for r in self.sessions[s]['data']:
1310						r['d13C_VPDB'] += offset				
1311				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
1312					a,b = np.polyfit(X,Y,1)
1313					for r in self.sessions[s]['data']:
1314						r['d13C_VPDB'] = a * r['d13C_VPDB'] + b
1315
1316	def standardize_d18O(self):
1317		'''
1318		Perform δ18O standadization within each session `s` according to
1319		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
1320		which is defined by default by `D47data.refresh_sessions()`as equal to
1321		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
1322		'''
1323		for s in self.sessions:
1324			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
1325				XY = [(r['d18O_VSMOW'], self.Nominal_d18O_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d18O_VPDB]
1326				X,Y = zip(*XY)
1327				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
1328				if self.sessions[s]['d18O_standardization_method'] == '1pt':
1329					offset = np.mean(Y) - np.mean(X)
1330					for r in self.sessions[s]['data']:
1331						r['d18O_VSMOW'] += offset				
1332				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
1333					a,b = np.polyfit(X,Y,1)
1334					for r in self.sessions[s]['data']:
1335						r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b
1336	
1337
1338	def compute_bulk_and_clumping_deltas(self, r):
1339		'''
1340		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
1341		'''
1342
1343		# Compute working gas R13, R18, and isobar ratios
1344		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
1345		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
1346		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
1347
1348		# Compute analyte isobar ratios
1349		R45 = (1 + r['d45'] / 1000) * R45_wg
1350		R46 = (1 + r['d46'] / 1000) * R46_wg
1351		R47 = (1 + r['d47'] / 1000) * R47_wg
1352		R48 = (1 + r['d48'] / 1000) * R48_wg
1353		R49 = (1 + r['d49'] / 1000) * R49_wg
1354
1355		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
1356		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
1357		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
1358
1359		# Compute stochastic isobar ratios of the analyte
1360		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
1361			R13, R18, D17O = r['D17O']
1362		)
1363
1364		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
1365		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
1366		if (R45 / R45stoch - 1) > 5e-8:
1367			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
1368		if (R46 / R46stoch - 1) > 5e-8:
1369			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
1370
1371		# Compute raw clumped isotope anomalies
1372		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
1373		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
1374		r['D49raw'] = 1000 * (R49 / R49stoch - 1)
1375
1376
1377	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
1378		'''
1379		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
1380		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
1381		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
1382		'''
1383
1384		# Compute R17
1385		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
1386
1387		# Compute isotope concentrations
1388		C12 = (1 + R13) ** -1
1389		C13 = C12 * R13
1390		C16 = (1 + R17 + R18) ** -1
1391		C17 = C16 * R17
1392		C18 = C16 * R18
1393
1394		# Compute stochastic isotopologue concentrations
1395		C626 = C16 * C12 * C16
1396		C627 = C16 * C12 * C17 * 2
1397		C628 = C16 * C12 * C18 * 2
1398		C636 = C16 * C13 * C16
1399		C637 = C16 * C13 * C17 * 2
1400		C638 = C16 * C13 * C18 * 2
1401		C727 = C17 * C12 * C17
1402		C728 = C17 * C12 * C18 * 2
1403		C737 = C17 * C13 * C17
1404		C738 = C17 * C13 * C18 * 2
1405		C828 = C18 * C12 * C18
1406		C838 = C18 * C13 * C18
1407
1408		# Compute stochastic isobar ratios
1409		R45 = (C636 + C627) / C626
1410		R46 = (C628 + C637 + C727) / C626
1411		R47 = (C638 + C728 + C737) / C626
1412		R48 = (C738 + C828) / C626
1413		R49 = C838 / C626
1414
1415		# Account for stochastic anomalies
1416		R47 *= 1 + D47 / 1000
1417		R48 *= 1 + D48 / 1000
1418		R49 *= 1 + D49 / 1000
1419
1420		# Return isobar ratios
1421		return R45, R46, R47, R48, R49
1422
1423
1424	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
1425		'''
1426		Split unknown samples by UID (treat all analyses as different samples)
1427		or by session (treat analyses of a given sample in different sessions as
1428		different samples).
1429
1430		**Parameters**
1431
1432		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
1433		+ `grouping`: `by_uid` | `by_session`
1434		'''
1435		if samples_to_split == 'all':
1436			samples_to_split = [s for s in self.unknowns]
1437		gkeys = {'by_uid':'UID', 'by_session':'Session'}
1438		self.grouping = grouping.lower()
1439		if self.grouping in gkeys:
1440			gkey = gkeys[self.grouping]
1441		for r in self:
1442			if r['Sample'] in samples_to_split:
1443				r['Sample_original'] = r['Sample']
1444				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
1445			elif r['Sample'] in self.unknowns:
1446				r['Sample_original'] = r['Sample']
1447		self.refresh_samples()
1448
1449
1450	def unsplit_samples(self, tables = False):
1451		'''
1452		Reverse the effects of `D47data.split_samples()`.
1453		
1454		This should only be used after `D4xdata.standardize()` with `method='pooled'`.
1455		
1456		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
1457		probably use `D4xdata.combine_samples()` instead to reverse the effects of
1458		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
1459		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
1460		that case session-averaged Δ4x values are statistically independent).
1461		'''
1462		unknowns_old = sorted({s for s in self.unknowns})
1463		CM_old = self.standardization.covar[:,:]
1464		VD_old = self.standardization.params.valuesdict().copy()
1465		vars_old = self.standardization.var_names
1466
1467		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
1468
1469		Ns = len(vars_old) - len(unknowns_old)
1470		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
1471		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
1472
1473		W = np.zeros((len(vars_new), len(vars_old)))
1474		W[:Ns,:Ns] = np.eye(Ns)
1475		for u in unknowns_new:
1476			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
1477			if self.grouping == 'by_session':
1478				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
1479			elif self.grouping == 'by_uid':
1480				weights = [1 for s in splits]
1481			sw = sum(weights)
1482			weights = [w/sw for w in weights]
1483			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
1484
1485		CM_new = W @ CM_old @ W.T
1486		V = W @ np.array([[VD_old[k]] for k in vars_old])
1487		VD_new = {k:v[0] for k,v in zip(vars_new, V)}
1488
1489		self.standardization.covar = CM_new
1490		self.standardization.params.valuesdict = lambda : VD_new
1491		self.standardization.var_names = vars_new
1492
1493		for r in self:
1494			if r['Sample'] in self.unknowns:
1495				r['Sample_split'] = r['Sample']
1496				r['Sample'] = r['Sample_original']
1497
1498		self.refresh_samples()
1499		self.consolidate_samples()
1500		self.repeatabilities()
1501
1502		if tables:
1503			self.table_of_analyses()
1504			self.table_of_samples()
1505
1506	def assign_timestamps(self):
1507		'''
1508		Assign a time field `t` of type `float` to each analysis.
1509
1510		If `TimeTag` is one of the data fields, `t` is equal within a given session
1511		to `TimeTag` minus the mean value of `TimeTag` for that session.
1512		Otherwise, `TimeTag` is by default equal to the index of each analysis
1513		in the dataset and `t` is defined as above.
1514		'''
1515		for session in self.sessions:
1516			sdata = self.sessions[session]['data']
1517			try:
1518				t0 = np.mean([r['TimeTag'] for r in sdata])
1519				for r in sdata:
1520					r['t'] = r['TimeTag'] - t0
1521			except KeyError:
1522				t0 = (len(sdata)-1)/2
1523				for t,r in enumerate(sdata):
1524					r['t'] = t - t0
1525
1526
1527	def report(self):
1528		'''
1529		Prints a report on the standardization fit.
1530		Only applicable after `D4xdata.standardize(method='pooled')`.
1531		'''
1532		report_fit(self.standardization)
1533
1534
1535	def combine_samples(self, sample_groups):
1536		'''
1537		Combine analyses of different samples to compute weighted average Δ4x
1538		and new error (co)variances corresponding to the groups defined by the `sample_groups`
1539		dictionary.
1540		
1541		Caution: samples are weighted by number of replicate analyses, which is a
1542		reasonable default behavior but is not always optimal (e.g., in the case of strongly
1543		correlated analytical errors for one or more samples).
1544		
1545		Returns a tuplet of:
1546		
1547		+ the list of group names
1548		+ an array of the corresponding Δ4x values
1549		+ the corresponding (co)variance matrix
1550		
1551		**Parameters**
1552
1553		+ `sample_groups`: a dictionary of the form:
1554		```py
1555		{'group1': ['sample_1', 'sample_2'],
1556		 'group2': ['sample_3', 'sample_4', 'sample_5']}
1557		```
1558		'''
1559		
1560		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
1561		groups = sorted(sample_groups.keys())
1562		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
1563		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
1564		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
1565		W = np.array([
1566			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
1567			for j in groups])
1568		D4x_new = W @ D4x_old
1569		CM_new = W @ CM_old @ W.T
1570
1571		return groups, D4x_new[:,0], CM_new
1572		
1573
1574	@make_verbal
1575	def standardize(self,
1576		method = 'pooled',
1577		weighted_sessions = [],
1578		consolidate = True,
1579		consolidate_tables = False,
1580		consolidate_plots = False,
1581		constraints = {},
1582		):
1583		'''
1584		Compute absolute Δ4x values for all replicate analyses and for sample averages.
1585		If `method` argument is set to `'pooled'`, the standardization processes all sessions
1586		in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
1587		i.e. that their true Δ4x value does not change between sessions,
1588		([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
1589		`'indep_sessions'`, the standardization processes each session independently, based only
1590		on anchors analyses.
1591		'''
1592
1593		self.standardization_method = method
1594		self.assign_timestamps()
1595
1596		if method == 'pooled':
1597			if weighted_sessions:
1598				for session_group in weighted_sessions:
1599					if self._4x == '47':
1600						X = D47data([r for r in self if r['Session'] in session_group])
1601					elif self._4x == '48':
1602						X = D48data([r for r in self if r['Session'] in session_group])
1603					X.Nominal_D4x = self.Nominal_D4x.copy()
1604					X.refresh()
1605					result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
1606					w = np.sqrt(result.redchi)
1607					self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
1608					for r in X:
1609						r[f'wD{self._4x}raw'] *= w
1610			else:
1611				self.msg(f'All D{self._4x}raw weights set to 1 ‰')
1612				for r in self:
1613					r[f'wD{self._4x}raw'] = 1.
1614
1615			params = Parameters()
1616			for k,session in enumerate(self.sessions):
1617				self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
1618				self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
1619				self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
1620				s = pf(session)
1621				params.add(f'a_{s}', value = 0.9)
1622				params.add(f'b_{s}', value = 0.)
1623				params.add(f'c_{s}', value = -0.9)
1624				params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift'])
1625				params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift'])
1626				params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift'])
1627			for sample in self.unknowns:
1628				params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)
1629
1630			for k in constraints:
1631				params[k].expr = constraints[k]
1632
1633			def residuals(p):
1634				R = []
1635				for r in self:
1636					session = pf(r['Session'])
1637					sample = pf(r['Sample'])
1638					if r['Sample'] in self.Nominal_D4x:
1639						R += [ (
1640							r[f'D{self._4x}raw'] - (
1641								p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
1642								+ p[f'b_{session}'] * r[f'd{self._4x}']
1643								+	p[f'c_{session}']
1644								+ r['t'] * (
1645									p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
1646									+ p[f'b2_{session}'] * r[f'd{self._4x}']
1647									+	p[f'c2_{session}']
1648									)
1649								)
1650							) / r[f'wD{self._4x}raw'] ]
1651					else:
1652						R += [ (
1653							r[f'D{self._4x}raw'] - (
1654								p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
1655								+ p[f'b_{session}'] * r[f'd{self._4x}']
1656								+	p[f'c_{session}']
1657								+ r['t'] * (
1658									p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
1659									+ p[f'b2_{session}'] * r[f'd{self._4x}']
1660									+	p[f'c2_{session}']
1661									)
1662								)
1663							) / r[f'wD{self._4x}raw'] ]
1664				return R
1665
1666			M = Minimizer(residuals, params)
1667			result = M.least_squares()
1668			self.Nf = result.nfree
1669			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
1670# 			if self.verbose:
1671# 				report_fit(result)
1672
1673			for r in self:
1674				s = pf(r["Session"])
1675				a = result.params.valuesdict()[f'a_{s}']
1676				b = result.params.valuesdict()[f'b_{s}']
1677				c = result.params.valuesdict()[f'c_{s}']
1678				a2 = result.params.valuesdict()[f'a2_{s}']
1679				b2 = result.params.valuesdict()[f'b2_{s}']
1680				c2 = result.params.valuesdict()[f'c2_{s}']
1681				r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
1682
1683			self.standardization = result
1684
1685			for session in self.sessions:
1686				self.sessions[session]['Np'] = 3
1687				for k in ['scrambling', 'slope', 'wg']:
1688					if self.sessions[session][f'{k}_drift']:
1689						self.sessions[session]['Np'] += 1
1690
1691			if consolidate:
1692				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
1693			return result
1694
1695
1696		elif method == 'indep_sessions':
1697
1698			if weighted_sessions:
1699				for session_group in weighted_sessions:
1700					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
1701					X.Nominal_D4x = self.Nominal_D4x.copy()
1702					X.refresh()
1703					# This is only done to assign r['wD47raw'] for r in X:
1704					X.standardize(method = method, weighted_sessions = [], consolidate = False)
1705					self.msg(f'D{self._4x}raw weights set to {1000*X[0][f"wD{self._4x}raw"]:.1f} ppm for sessions in {session_group}')
1706			else:
1707				self.msg('All weights set to 1 ‰')
1708				for r in self:
1709					r[f'wD{self._4x}raw'] = 1
1710
1711			for session in self.sessions:
1712				s = self.sessions[session]
1713				p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
1714				p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
1715				s['Np'] = sum(p_active)
1716				sdata = s['data']
1717
1718				A = np.array([
1719					[
1720						self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
1721						r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
1722						1 / r[f'wD{self._4x}raw'],
1723						self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
1724						r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
1725						r['t'] / r[f'wD{self._4x}raw']
1726						]
1727					for r in sdata if r['Sample'] in self.anchors
1728					])[:,p_active] # only keep columns for the active parameters
1729				Y = np.array([[r[f'D{self._4x}raw'] / r[f'wD{self._4x}raw']] for r in sdata if r['Sample'] in self.anchors])
1730				s['Na'] = Y.size
1731				CM = linalg.inv(A.T @ A)
1732				bf = (CM @ A.T @ Y).T[0,:]
1733				k = 0
1734				for n,a in zip(p_names, p_active):
1735					if a:
1736						s[n] = bf[k]
1737# 						self.msg(f'{n} = {bf[k]}')
1738						k += 1
1739					else:
1740						s[n] = 0.
1741# 						self.msg(f'{n} = 0.0')
1742
1743				for r in sdata :
1744					a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
1745					r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
1746					r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])
1747
1748				s['CM'] = np.zeros((6,6))
1749				i = 0
1750				k_active = [j for j,a in enumerate(p_active) if a]
1751				for j,a in enumerate(p_active):
1752					if a:
1753						s['CM'][j,k_active] = CM[i,:]
1754						i += 1
1755
1756			if not weighted_sessions:
1757				w = self.rmswd()['rmswd']
1758				for r in self:
1759						r[f'wD{self._4x}'] *= w
1760						r[f'wD{self._4x}raw'] *= w
1761				for session in self.sessions:
1762					self.sessions[session]['CM'] *= w**2
1763
1764			for session in self.sessions:
1765				s = self.sessions[session]
1766				s['SE_a'] = s['CM'][0,0]**.5
1767				s['SE_b'] = s['CM'][1,1]**.5
1768				s['SE_c'] = s['CM'][2,2]**.5
1769				s['SE_a2'] = s['CM'][3,3]**.5
1770				s['SE_b2'] = s['CM'][4,4]**.5
1771				s['SE_c2'] = s['CM'][5,5]**.5
1772
1773			if not weighted_sessions:
1774				self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
1775			else:
1776				self.Nf = 0
1777				for sg in weighted_sessions:
1778					self.Nf += self.rmswd(sessions = sg)['Nf']
1779
1780			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
1781
1782			avgD4x = {
1783				sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
1784				for sample in self.samples
1785				}
1786			chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
1787			rD4x = (chi2/self.Nf)**.5
1788			self.repeatability[f'sigma_{self._4x}'] = rD4x
1789
1790			if consolidate:
1791				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
1792
1793
1794	def standardization_error(self, session, d4x, D4x, t = 0):
1795		'''
1796		Compute standardization error for a given session and
1797		(δ47, Δ47) composition.
1798		'''
1799		a = self.sessions[session]['a']
1800		b = self.sessions[session]['b']
1801		c = self.sessions[session]['c']
1802		a2 = self.sessions[session]['a2']
1803		b2 = self.sessions[session]['b2']
1804		c2 = self.sessions[session]['c2']
1805		CM = self.sessions[session]['CM']
1806
1807		x, y = D4x, d4x
1808		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
1809# 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
1810		dxdy = -(b+b2*t) / (a+a2*t)
1811		dxdz = 1. / (a+a2*t)
1812		dxda = -x / (a+a2*t)
1813		dxdb = -y / (a+a2*t)
1814		dxdc = -1. / (a+a2*t)
1815		dxda2 = -x * a2 / (a+a2*t)
1816		dxdb2 = -y * t / (a+a2*t)
1817		dxdc2 = -t / (a+a2*t)
1818		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
1819		sx = (V @ CM @ V.T) ** .5
1820		return sx
1821
1822
1823	@make_verbal
1824	def summary(self,
1825		dir = 'output',
1826		filename = None,
1827		save_to_file = True,
1828		print_out = True,
1829		):
1830		'''
1831		Print out an/or save to disk a summary of the standardization results.
1832
1833		**Parameters**
1834
1835		+ `dir`: the directory in which to save the table
1836		+ `filename`: the name to the csv file to write to
1837		+ `save_to_file`: whether to save the table to disk
1838		+ `print_out`: whether to print out the table
1839		'''
1840
1841		out = []
1842		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
1843		out += [['N analyses (anchors + unknowns)', f"{len(self)} ({len([r for r in self if r['Sample'] in self.anchors])} + {len([r for r in self if r['Sample'] in self.unknowns])})"]]
1844		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
1845		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
1846		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
1847		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
1848		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
1849		out += [['Model degrees of freedom', f"{self.Nf}"]]
1850		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
1851		out += [['Standardization method', self.standardization_method]]
1852
1853		if save_to_file:
1854			if not os.path.exists(dir):
1855				os.makedirs(dir)
1856			if filename is None:
1857				filename = f'D{self._4x}_summary.csv'
1858			with open(f'{dir}/{filename}', 'w') as fid:
1859				fid.write(make_csv(out))
1860		if print_out:
1861			self.msg('\n' + pretty_table(out, header = 0))
1862
1863
1864	@make_verbal
1865	def table_of_sessions(self,
1866		dir = 'output',
1867		filename = None,
1868		save_to_file = True,
1869		print_out = True,
1870		output = None,
1871		):
1872		'''
1873		Print out an/or save to disk a table of sessions.
1874
1875		**Parameters**
1876
1877		+ `dir`: the directory in which to save the table
1878		+ `filename`: the name to the csv file to write to
1879		+ `save_to_file`: whether to save the table to disk
1880		+ `print_out`: whether to print out the table
1881		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
1882		    if set to `'raw'`: return a list of list of strings
1883		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
1884		'''
1885		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
1886		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
1887		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
1888
1889		out = [['Session','Na','Nu','d13Cwg_VPDB','d18Owg_VSMOW','r_d13C','r_d18O',f'r_D{self._4x}','a ± SE','1e3 x b ± SE','c ± SE']]
1890		if include_a2:
1891			out[-1] += ['a2 ± SE']
1892		if include_b2:
1893			out[-1] += ['b2 ± SE']
1894		if include_c2:
1895			out[-1] += ['c2 ± SE']
1896		for session in self.sessions:
1897			out += [[
1898				session,
1899				f"{self.sessions[session]['Na']}",
1900				f"{self.sessions[session]['Nu']}",
1901				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
1902				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
1903				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
1904				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
1905				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
1906				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
1907				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
1908				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
1909				]]
1910			if include_a2:
1911				if self.sessions[session]['scrambling_drift']:
1912					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
1913				else:
1914					out[-1] += ['']
1915			if include_b2:
1916				if self.sessions[session]['slope_drift']:
1917					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
1918				else:
1919					out[-1] += ['']
1920			if include_c2:
1921				if self.sessions[session]['wg_drift']:
1922					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
1923				else:
1924					out[-1] += ['']
1925
1926		if save_to_file:
1927			if not os.path.exists(dir):
1928				os.makedirs(dir)
1929			if filename is None:
1930				filename = f'D{self._4x}_sessions.csv'
1931			with open(f'{dir}/{filename}', 'w') as fid:
1932				fid.write(make_csv(out))
1933		if print_out:
1934			self.msg('\n' + pretty_table(out))
1935		if output == 'raw':
1936			return out
1937		elif output == 'pretty':
1938			return pretty_table(out)
1939
1940
1941	@make_verbal
1942	def table_of_analyses(
1943		self,
1944		dir = 'output',
1945		filename = None,
1946		save_to_file = True,
1947		print_out = True,
1948		output = None,
1949		):
1950		'''
1951		Print out an/or save to disk a table of analyses.
1952
1953		**Parameters**
1954
1955		+ `dir`: the directory in which to save the table
1956		+ `filename`: the name to the csv file to write to
1957		+ `save_to_file`: whether to save the table to disk
1958		+ `print_out`: whether to print out the table
1959		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
1960		    if set to `'raw'`: return a list of list of strings
1961		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
1962		'''
1963
1964		out = [['UID','Session','Sample']]
1965		extra_fields = [f for f in [('SampleMass','.2f'),('ColdFingerPressure','.1f'),('AcidReactionYield','.3f')] if f[0] in {k for r in self for k in r}]
1966		for f in extra_fields:
1967			out[-1] += [f[0]]
1968		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
1969		for r in self:
1970			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
1971			for f in extra_fields:
1972				out[-1] += [f"{r[f[0]]:{f[1]}}"]
1973			out[-1] += [
1974				f"{r['d13Cwg_VPDB']:.3f}",
1975				f"{r['d18Owg_VSMOW']:.3f}",
1976				f"{r['d45']:.6f}",
1977				f"{r['d46']:.6f}",
1978				f"{r['d47']:.6f}",
1979				f"{r['d48']:.6f}",
1980				f"{r['d49']:.6f}",
1981				f"{r['d13C_VPDB']:.6f}",
1982				f"{r['d18O_VSMOW']:.6f}",
1983				f"{r['D47raw']:.6f}",
1984				f"{r['D48raw']:.6f}",
1985				f"{r['D49raw']:.6f}",
1986				f"{r[f'D{self._4x}']:.6f}"
1987				]
1988		if save_to_file:
1989			if not os.path.exists(dir):
1990				os.makedirs(dir)
1991			if filename is None:
1992				filename = f'D{self._4x}_analyses.csv'
1993			with open(f'{dir}/{filename}', 'w') as fid:
1994				fid.write(make_csv(out))
1995		if print_out:
1996			self.msg('\n' + pretty_table(out))
1997		return out
1998
1999	@make_verbal
2000	def covar_table(
2001		self,
2002		correl = False,
2003		dir = 'output',
2004		filename = None,
2005		save_to_file = True,
2006		print_out = True,
2007		output = None,
2008		):
2009		'''
2010		Print out, save to disk and/or return the variance-covariance matrix of D4x
2011		for all unknown samples.
2012
2013		**Parameters**
2014
2015		+ `dir`: the directory in which to save the csv
2016		+ `filename`: the name of the csv file to write to
2017		+ `save_to_file`: whether to save the csv
2018		+ `print_out`: whether to print out the matrix
2019		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
2020		    if set to `'raw'`: return a list of list of strings
2021		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
2022		'''
2023		samples = sorted([u for u in self.unknowns])
2024		out = [[''] + samples]
2025		for s1 in samples:
2026			out.append([s1])
2027			for s2 in samples:
2028				if correl:
2029					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
2030				else:
2031					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
2032
2033		if save_to_file:
2034			if not os.path.exists(dir):
2035				os.makedirs(dir)
2036			if filename is None:
2037				if correl:
2038					filename = f'D{self._4x}_correl.csv'
2039				else:
2040					filename = f'D{self._4x}_covar.csv'
2041			with open(f'{dir}/{filename}', 'w') as fid:
2042				fid.write(make_csv(out))
2043		if print_out:
2044			self.msg('\n'+pretty_table(out))
2045		if output == 'raw':
2046			return out
2047		elif output == 'pretty':
2048			return pretty_table(out)
2049
2050	@make_verbal
2051	def table_of_samples(
2052		self,
2053		dir = 'output',
2054		filename = None,
2055		save_to_file = True,
2056		print_out = True,
2057		output = None,
2058		):
2059		'''
2060		Print out, save to disk and/or return a table of samples.
2061
2062		**Parameters**
2063
2064		+ `dir`: the directory in which to save the csv
2065		+ `filename`: the name of the csv file to write to
2066		+ `save_to_file`: whether to save the csv
2067		+ `print_out`: whether to print out the table
2068		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
2069		    if set to `'raw'`: return a list of list of strings
2070		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
2071		'''
2072
2073		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
2074		for sample in self.anchors:
2075			out += [[
2076				f"{sample}",
2077				f"{self.samples[sample]['N']}",
2078				f"{self.samples[sample]['d13C_VPDB']:.2f}",
2079				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
2080				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
2081				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
2082				]]
2083		for sample in self.unknowns:
2084			out += [[
2085				f"{sample}",
2086				f"{self.samples[sample]['N']}",
2087				f"{self.samples[sample]['d13C_VPDB']:.2f}",
2088				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
2089				f"{self.samples[sample][f'D{self._4x}']:.4f}",
2090				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
2091				f{self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
2092				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
2093				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
2094				]]
2095		if save_to_file:
2096			if not os.path.exists(dir):
2097				os.makedirs(dir)
2098			if filename is None:
2099				filename = f'D{self._4x}_samples.csv'
2100			with open(f'{dir}/{filename}', 'w') as fid:
2101				fid.write(make_csv(out))
2102		if print_out:
2103			self.msg('\n'+pretty_table(out))
2104		if output == 'raw':
2105			return out
2106		elif output == 'pretty':
2107			return pretty_table(out)
2108
2109
2110	def plot_sessions(self, dir = 'output', figsize = (8,8)):
2111		'''
2112		Generate session plots and save them to disk.
2113
2114		**Parameters**
2115
2116		+ `dir`: the directory in which to save the plots
2117		+ `figsize`: the width and height (in inches) of each plot
2118		'''
2119		if not os.path.exists(dir):
2120			os.makedirs(dir)
2121
2122		for session in self.sessions:
2123			sp = self.plot_single_session(session, xylimits = 'constant')
2124			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
2125			ppl.close(sp.fig)
2126
2127
2128	@make_verbal
2129	def consolidate_samples(self):
2130		'''
2131		Compile various statistics for each sample.
2132
2133		For each anchor sample:
2134
2135		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
2136		+ `SE_D47` or `SE_D48`: set to zero by definition
2137
2138		For each unknown sample:
2139
2140		+ `D47` or `D48`: the standardized Δ4x value for this unknown
2141		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown
2142
2143		For each anchor and unknown:
2144
2145		+ `N`: the total number of analyses of this sample
2146		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
2147		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
2148		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
2149		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
2150		variance, indicating whether the Δ4x repeatability this sample differs significantly from
2151		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
2152		'''
2153		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
2154		for sample in self.samples:
2155			self.samples[sample]['N'] = len(self.samples[sample]['data'])
2156			if self.samples[sample]['N'] > 1:
2157				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
2158
2159			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
2160			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
2161
2162			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
2163			if len(D4x_pop) > 2:
2164				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
2165
2166		if self.standardization_method == 'pooled':
2167			for sample in self.anchors:
2168				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
2169				self.samples[sample][f'SE_D{self._4x}'] = 0.
2170			for sample in self.unknowns:
2171				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
2172				try:
2173					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
2174				except ValueError:
2175					# when `sample` is constrained by self.standardize(constraints = {...}),
2176					# it is no longer listed in self.standardization.var_names.
2177					# Temporary fix: define SE as zero for now
2178					self.samples[sample][f'SE_D4{self._4x}'] = 0.
2179
2180		elif self.standardization_method == 'indep_sessions':
2181			for sample in self.anchors:
2182				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
2183				self.samples[sample][f'SE_D{self._4x}'] = 0.
2184			for sample in self.unknowns:
2185				self.msg(f'Consolidating sample {sample}')
2186				self.unknowns[sample][f'session_D{self._4x}'] = {}
2187				session_avg = []
2188				for session in self.sessions:
2189					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
2190					if sdata:
2191						self.msg(f'{sample} found in session {session}')
2192						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
2193						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
2194						# !! TODO: sigma_s below does not account for temporal changes in standardization error
2195						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
2196						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
2197						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
2198						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
2199				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
2200				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
2201				wsum = sum([weights[s] for s in weights])
2202				for s in weights:
2203					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]
2204
2205
2206	def consolidate_sessions(self):
2207		'''
2208		Compute various statistics for each session.
2209
2210		+ `Na`: Number of anchor analyses in the session
2211		+ `Nu`: Number of unknown analyses in the session
2212		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
2213		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
2214		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
2215		+ `a`: scrambling factor
2216		+ `b`: compositional slope
2217		+ `c`: WG offset
2218		+ `SE_a`: Model stadard erorr of `a`
2219		+ `SE_b`: Model stadard erorr of `b`
2220		+ `SE_c`: Model stadard erorr of `c`
2221		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
2222		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
2223		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
2224		+ `a2`: scrambling factor drift
2225		+ `b2`: compositional slope drift
2226		+ `c2`: WG offset drift
2227		+ `Np`: Number of standardization parameters to fit
2228		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
2229		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
2230		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
2231		'''
2232		for session in self.sessions:
2233			if 'd13Cwg_VPDB' not in self.sessions[session]:
2234				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
2235			if 'd18Owg_VSMOW' not in self.sessions[session]:
2236				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
2237			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
2238			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
2239
2240			self.msg(f'Computing repeatabilities for session {session}')
2241			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
2242			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
2243			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
2244
2245		if self.standardization_method == 'pooled':
2246			for session in self.sessions:
2247
2248				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
2249				i = self.standardization.var_names.index(f'a_{pf(session)}')
2250				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
2251
2252				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
2253				i = self.standardization.var_names.index(f'b_{pf(session)}')
2254				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
2255
2256				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
2257				i = self.standardization.var_names.index(f'c_{pf(session)}')
2258				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
2259
2260				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
2261				if self.sessions[session]['scrambling_drift']:
2262					i = self.standardization.var_names.index(f'a2_{pf(session)}')
2263					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
2264				else:
2265					self.sessions[session]['SE_a2'] = 0.
2266
2267				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
2268				if self.sessions[session]['slope_drift']:
2269					i = self.standardization.var_names.index(f'b2_{pf(session)}')
2270					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
2271				else:
2272					self.sessions[session]['SE_b2'] = 0.
2273
2274				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
2275				if self.sessions[session]['wg_drift']:
2276					i = self.standardization.var_names.index(f'c2_{pf(session)}')
2277					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
2278				else:
2279					self.sessions[session]['SE_c2'] = 0.
2280
2281				i = self.standardization.var_names.index(f'a_{pf(session)}')
2282				j = self.standardization.var_names.index(f'b_{pf(session)}')
2283				k = self.standardization.var_names.index(f'c_{pf(session)}')
2284				CM = np.zeros((6,6))
2285				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
2286				try:
2287					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
2288					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
2289					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
2290					try:
2291						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
2292						CM[3,4] = self.standardization.covar[i2,j2]
2293						CM[4,3] = self.standardization.covar[j2,i2]
2294					except ValueError:
2295						pass
2296					try:
2297						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2298						CM[3,5] = self.standardization.covar[i2,k2]
2299						CM[5,3] = self.standardization.covar[k2,i2]
2300					except ValueError:
2301						pass
2302				except ValueError:
2303					pass
2304				try:
2305					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
2306					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
2307					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
2308					try:
2309						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2310						CM[4,5] = self.standardization.covar[j2,k2]
2311						CM[5,4] = self.standardization.covar[k2,j2]
2312					except ValueError:
2313						pass
2314				except ValueError:
2315					pass
2316				try:
2317					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2318					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
2319					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
2320				except ValueError:
2321					pass
2322
2323				self.sessions[session]['CM'] = CM
2324
2325		elif self.standardization_method == 'indep_sessions':
2326			pass # Not implemented yet
2327
2328
2329	@make_verbal
2330	def repeatabilities(self):
2331		'''
2332		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
2333		(for all samples, for anchors, and for unknowns).
2334		'''
2335		self.msg('Computing reproducibilities for all sessions')
2336
2337		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
2338		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
2339		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
2340		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
2341		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
2342
2343
2344	@make_verbal
2345	def consolidate(self, tables = True, plots = True):
2346		'''
2347		Collect information about samples, sessions and repeatabilities.
2348		'''
2349		self.consolidate_samples()
2350		self.consolidate_sessions()
2351		self.repeatabilities()
2352
2353		if tables:
2354			self.summary()
2355			self.table_of_sessions()
2356			self.table_of_analyses()
2357			self.table_of_samples()
2358
2359		if plots:
2360			self.plot_sessions()
2361
2362
2363	@make_verbal
2364	def rmswd(self,
2365		samples = 'all samples',
2366		sessions = 'all sessions',
2367		):
2368		'''
2369		Compute the χ2, root mean squared weighted deviation
2370		(i.e. reduced χ2), and corresponding degrees of freedom of the
2371		Δ4x values for samples in `samples` and sessions in `sessions`.
2372		
2373		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
2374		'''
2375		if samples == 'all samples':
2376			mysamples = [k for k in self.samples]
2377		elif samples == 'anchors':
2378			mysamples = [k for k in self.anchors]
2379		elif samples == 'unknowns':
2380			mysamples = [k for k in self.unknowns]
2381		else:
2382			mysamples = samples
2383
2384		if sessions == 'all sessions':
2385			sessions = [k for k in self.sessions]
2386
2387		chisq, Nf = 0, 0
2388		for sample in mysamples :
2389			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2390			if len(G) > 1 :
2391				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
2392				Nf += (len(G) - 1)
2393				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
2394		r = (chisq / Nf)**.5 if Nf > 0 else 0
2395		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
2396		return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}
2397
2398	
2399	@make_verbal
2400	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
2401		'''
2402		Compute the repeatability of `[r[key] for r in self]`
2403		'''
2404		# NB: it's debatable whether rD47 should be computed
2405		# with Nf = len(self)-len(self.samples) instead of
2406		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
2407
2408		if samples == 'all samples':
2409			mysamples = [k for k in self.samples]
2410		elif samples == 'anchors':
2411			mysamples = [k for k in self.anchors]
2412		elif samples == 'unknowns':
2413			mysamples = [k for k in self.unknowns]
2414		else:
2415			mysamples = samples
2416
2417		if sessions == 'all sessions':
2418			sessions = [k for k in self.sessions]
2419
2420		if key in ['D47', 'D48']:
2421			chisq, Nf = 0, 0
2422			for sample in mysamples :
2423				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2424				if len(X) > 1 :
2425					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
2426					if sample in self.unknowns:
2427						Nf += len(X) - 1
2428					else:
2429						Nf += len(X)
2430			if samples in ['anchors', 'all samples']:
2431				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
2432			r = (chisq / Nf)**.5 if Nf > 0 else 0
2433
2434		else: # if key not in ['D47', 'D48']
2435			chisq, Nf = 0, 0
2436			for sample in mysamples :
2437				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2438				if len(X) > 1 :
2439					Nf += len(X) - 1
2440					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
2441			r = (chisq / Nf)**.5 if Nf > 0 else 0
2442
2443		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
2444		return r
2445
2446	def sample_average(self, samples, weights = 'equal', normalize = True):
2447		'''
2448		Weighted average Δ4x value of a group of samples, accounting for covariance.
2449
2450		Returns the weighed average Δ4x value and associated SE
2451		of a group of samples. Weights are equal by default. If `normalize` is
2452		true, `weights` will be rescaled so that their sum equals 1.
2453
2454		**Examples**
2455
2456		```python
2457		self.sample_average(['X','Y'], [1, 2])
2458		```
2459
2460		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
2461		where Δ4x(X) and Δ4x(Y) are the average Δ4x
2462		values of samples X and Y, respectively.
2463
2464		```python
2465		self.sample_average(['X','Y'], [1, -1], normalize = False)
2466		```
2467
2468		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
2469		'''
2470		if weights == 'equal':
2471			weights = [1/len(samples)] * len(samples)
2472
2473		if normalize:
2474			s = sum(weights)
2475			if s:
2476				weights = [w/s for w in weights]
2477
2478		try:
2479# 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
2480# 			C = self.standardization.covar[indices,:][:,indices]
2481			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
2482			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
2483			return correlated_sum(X, C, weights)
2484		except ValueError:
2485			return (0., 0.)
2486
2487
2488	def sample_D4x_covar(self, sample1, sample2 = None):
2489		'''
2490		Covariance between Δ4x values of samples
2491
2492		Returns the error covariance between the average Δ4x values of two
2493		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
2494		returns the Δ4x variance for that sample.
2495		'''
2496		if sample2 is None:
2497			sample2 = sample1
2498		if self.standardization_method == 'pooled':
2499			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
2500			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
2501			return self.standardization.covar[i, j]
2502		elif self.standardization_method == 'indep_sessions':
2503			if sample1 == sample2:
2504				return self.samples[sample1][f'SE_D{self._4x}']**2
2505			else:
2506				c = 0
2507				for session in self.sessions:
2508					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
2509					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
2510					if sdata1 and sdata2:
2511						a = self.sessions[session]['a']
2512						# !! TODO: CM below does not account for temporal changes in standardization parameters
2513						CM = self.sessions[session]['CM'][:3,:3]
2514						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
2515						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
2516						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
2517						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
2518						c += (
2519							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
2520							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
2521							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
2522							@ CM
2523							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
2524							) / a**2
2525				return float(c)
2526
2527	def sample_D4x_correl(self, sample1, sample2 = None):
2528		'''
2529		Correlation between Δ4x errors of samples
2530
2531		Returns the error correlation between the average Δ4x values of two samples.
2532		'''
2533		if sample2 is None or sample2 == sample1:
2534			return 1.
2535		return (
2536			self.sample_D4x_covar(sample1, sample2)
2537			/ self.unknowns[sample1][f'SE_D{self._4x}']
2538			/ self.unknowns[sample2][f'SE_D{self._4x}']
2539			)
2540
2541	def plot_single_session(self,
2542		session,
2543		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
2544		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
2545		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
2546		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
2547		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
2548		xylimits = 'free', # | 'constant'
2549		x_label = None,
2550		y_label = None,
2551		error_contour_interval = 'auto',
2552		fig = 'new',
2553		):
2554		'''
2555		Generate plot for a single session
2556		'''
2557		if x_label is None:
2558			x_label = f'δ$_{{{self._4x}}}$ (‰)'
2559		if y_label is None:
2560			y_label = f'Δ$_{{{self._4x}}}$ (‰)'
2561
2562		out = _SessionPlot()
2563		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
2564		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
2565		
2566		if fig == 'new':
2567			out.fig = ppl.figure(figsize = (6,6))
2568			ppl.subplots_adjust(.1,.1,.9,.9)
2569
2570		out.anchor_analyses, = ppl.plot(
2571			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
2572			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
2573			**kw_plot_anchors)
2574		out.unknown_analyses, = ppl.plot(
2575			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
2576			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
2577			**kw_plot_unknowns)
2578		out.anchor_avg = ppl.plot(
2579			np.array([ np.array([
2580				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
2581				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
2582				]) for sample in anchors]).T,
2583			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
2584			**kw_plot_anchor_avg)
2585		out.unknown_avg = ppl.plot(
2586			np.array([ np.array([
2587				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
2588				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
2589				]) for sample in unknowns]).T,
2590			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
2591			**kw_plot_unknown_avg)
2592		if xylimits == 'constant':
2593			x = [r[f'd{self._4x}'] for r in self]
2594			y = [r[f'D{self._4x}'] for r in self]
2595			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
2596			w, h = x2-x1, y2-y1
2597			x1 -= w/20
2598			x2 += w/20
2599			y1 -= h/20
2600			y2 += h/20
2601			ppl.axis([x1, x2, y1, y2])
2602		elif xylimits == 'free':
2603			x1, x2, y1, y2 = ppl.axis()
2604		else:
2605			x1, x2, y1, y2 = ppl.axis(xylimits)
2606				
2607		if error_contour_interval != 'none':
2608			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
2609			XI,YI = np.meshgrid(xi, yi)
2610			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
2611			if error_contour_interval == 'auto':
2612				rng = np.max(SI) - np.min(SI)
2613				if rng <= 0.01:
2614					cinterval = 0.001
2615				elif rng <= 0.03:
2616					cinterval = 0.004
2617				elif rng <= 0.1:
2618					cinterval = 0.01
2619				elif rng <= 0.3:
2620					cinterval = 0.03
2621				elif rng <= 1.:
2622					cinterval = 0.1
2623				else:
2624					cinterval = 0.5
2625			else:
2626				cinterval = error_contour_interval
2627
2628			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
2629			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
2630			out.clabel = ppl.clabel(out.contour)
2631
2632		ppl.xlabel(x_label)
2633		ppl.ylabel(y_label)
2634		ppl.title(session, weight = 'bold')
2635		ppl.grid(alpha = .2)
2636		out.ax = ppl.gca()		
2637
2638		return out
2639
2640	def plot_residuals(
2641		self,
2642		hist = False,
2643		binwidth = 2/3,
2644		dir = 'output',
2645		filename = None,
2646		highlight = [],
2647		colors = None,
2648		figsize = None,
2649		):
2650		'''
2651		Plot residuals of each analysis as a function of time (actually, as a function of
2652		the order of analyses in the `D4xdata` object)
2653
2654		+ `hist`: whether to add a histogram of residuals
2655		+ `histbins`: specify bin edges for the histogram
2656		+ `dir`: the directory in which to save the plot
2657		+ `highlight`: a list of samples to highlight
2658		+ `colors`: a dict of `{<sample>: <color>}` for all samples
2659		+ `figsize`: (width, height) of figure
2660		'''
2661		# Layout
2662		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
2663		if hist:
2664			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
2665			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
2666		else:
2667			ppl.subplots_adjust(.08,.05,.78,.8)
2668			ax1 = ppl.subplot(111)
2669		
2670		# Colors
2671		N = len(self.anchors)
2672		if colors is None:
2673			if len(highlight) > 0:
2674				Nh = len(highlight)
2675				if Nh == 1:
2676					colors = {highlight[0]: (0,0,0)}
2677				elif Nh == 3:
2678					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
2679				elif Nh == 4:
2680					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
2681				else:
2682					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
2683			else:
2684				if N == 3:
2685					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
2686				elif N == 4:
2687					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
2688				else:
2689					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}
2690
2691		ppl.sca(ax1)
2692		
2693		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)
2694
2695		session = self[0]['Session']
2696		x1 = 0
2697# 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
2698		x_sessions = {}
2699		one_or_more_singlets = False
2700		one_or_more_multiplets = False
2701		multiplets = set()
2702		for k,r in enumerate(self):
2703			if r['Session'] != session:
2704				x2 = k-1
2705				x_sessions[session] = (x1+x2)/2
2706				ppl.axvline(k - 0.5, color = 'k', lw = .5)
2707				session = r['Session']
2708				x1 = k
2709			singlet = len(self.samples[r['Sample']]['data']) == 1
2710			if not singlet:
2711				multiplets.add(r['Sample'])
2712			if r['Sample'] in self.unknowns:
2713				if singlet:
2714					one_or_more_singlets = True
2715				else:
2716					one_or_more_multiplets = True
2717			kw = dict(
2718				marker = 'x' if singlet else '+',
2719				ms = 4 if singlet else 5,
2720				ls = 'None',
2721				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
2722				mew = 1,
2723				alpha = 0.2 if singlet else 1,
2724				)
2725			if highlight and r['Sample'] not in highlight:
2726				kw['alpha'] = 0.2
2727			ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
2728		x2 = k
2729		x_sessions[session] = (x1+x2)/2
2730
2731		ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
2732		ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
2733		if not hist:
2734			ppl.text(len(self), self.repeatability['r_D47']*1000, f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
2735			ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f"   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center')
2736
2737		xmin, xmax, ymin, ymax = ppl.axis()
2738		for s in x_sessions:
2739			ppl.text(
2740				x_sessions[s],
2741				ymax +1,
2742				s,
2743				va = 'bottom',
2744				**(
2745					dict(ha = 'center')
2746					if len(self.sessions[s]['data']) > (0.15 * len(self))
2747					else dict(ha = 'left', rotation = 45)
2748					)
2749				)
2750
2751		if hist:
2752			ppl.sca(ax2)
2753
2754		for s in colors:
2755			kw['marker'] = '+'
2756			kw['ms'] = 5
2757			kw['mec'] = colors[s]
2758			kw['label'] = s
2759			kw['alpha'] = 1
2760			ppl.plot([], [], **kw)
2761
2762		kw['mec'] = (0,0,0)
2763
2764		if one_or_more_singlets:
2765			kw['marker'] = 'x'
2766			kw['ms'] = 4
2767			kw['alpha'] = .2
2768			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
2769			ppl.plot([], [], **kw)
2770
2771		if one_or_more_multiplets:
2772			kw['marker'] = '+'
2773			kw['ms'] = 4
2774			kw['alpha'] = 1
2775			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
2776			ppl.plot([], [], **kw)
2777
2778		if hist:
2779			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
2780		else:
2781			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
2782		leg.set_zorder(-1000)
2783
2784		ppl.sca(ax1)
2785
2786		ppl.ylabel('Δ$_{47}$ residuals (ppm)')
2787		ppl.xticks([])
2788		ppl.axis([-1, len(self), None, None])
2789
2790		if hist:
2791			ppl.sca(ax2)
2792			X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self if r['Sample'] in multiplets]
2793			ppl.hist(
2794				X,
2795				orientation = 'horizontal',
2796				histtype = 'stepfilled',
2797				ec = [.4]*3,
2798				fc = [.25]*3,
2799				alpha = .25,
2800				bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
2801				)
2802			ppl.axis([None, None, ymin, ymax])
2803			ppl.text(0, 0,
2804				f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm\n   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
2805				size = 8,
2806				alpha = 1,
2807				va = 'center',
2808				ha = 'left',
2809				)
2810
2811			ppl.xticks([])
2812			ppl.yticks([])
2813# 			ax2.spines['left'].set_visible(False)
2814			ax2.spines['right'].set_visible(False)
2815			ax2.spines['top'].set_visible(False)
2816			ax2.spines['bottom'].set_visible(False)
2817
2818
2819		if not os.path.exists(dir):
2820			os.makedirs(dir)
2821		if filename is None:
2822			return fig
2823		elif filename == '':
2824			filename = f'D{self._4x}_residuals.pdf'
2825		ppl.savefig(f'{dir}/{filename}')
2826		ppl.close(fig)
2827				
2828
2829	def simulate(self, *args, **kwargs):
2830		'''
2831		Legacy function with warning message pointing to `virtual_data()`
2832		'''
2833		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
2834
2835	def plot_distribution_of_analyses(
2836		self,
2837		dir = 'output',
2838		filename = None,
2839		vs_time = False,
2840		figsize = (6,4),
2841		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
2842		output = None,
2843		):
2844		'''
2845		Plot temporal distribution of all analyses in the data set.
2846		
2847		**Parameters**
2848
2849		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
2850		'''
2851
2852		asamples = [s for s in self.anchors]
2853		usamples = [s for s in self.unknowns]
2854		if output is None or output == 'fig':
2855			fig = ppl.figure(figsize = figsize)
2856			ppl.subplots_adjust(*subplots_adjust)
2857		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
2858		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
2859		Xmax += (Xmax-Xmin)/40
2860		Xmin -= (Xmax-Xmin)/41
2861		for k, s in enumerate(asamples + usamples):
2862			if vs_time:
2863				X = [r['TimeTag'] for r in self if r['Sample'] == s]
2864			else:
2865				X = [x for x,r in enumerate(self) if r['Sample'] == s]
2866			Y = [-k for x in X]
2867			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
2868			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
2869			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
2870		ppl.axis([Xmin, Xmax, -k-1, 1])
2871		ppl.xlabel('\ntime')
2872		ppl.gca().annotate('',
2873			xy = (0.6, -0.02),
2874			xycoords = 'axes fraction',
2875			xytext = (.4, -0.02), 
2876            arrowprops = dict(arrowstyle = "->", color = 'k'),
2877            )
2878			
2879
2880		x2 = -1
2881		for session in self.sessions:
2882			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
2883			if vs_time:
2884				ppl.axvline(x1, color = 'k', lw = .75)
2885			if x2 > -1:
2886				if not vs_time:
2887					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
2888			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
2889# 			from xlrd import xldate_as_datetime
2890# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
2891			if vs_time:
2892				ppl.axvline(x2, color = 'k', lw = .75)
2893				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
2894			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
2895
2896		ppl.xticks([])
2897		ppl.yticks([])
2898
2899		if output is None:
2900			if not os.path.exists(dir):
2901				os.makedirs(dir)
2902			if filename == None:
2903				filename = f'D{self._4x}_distribution_of_analyses.pdf'
2904			ppl.savefig(f'{dir}/{filename}')
2905			ppl.close(fig)
2906		elif output == 'ax':
2907			return ppl.gca()
2908		elif output == 'fig':
2909			return fig
2910
2911
2912class D47data(D4xdata):
2913	'''
2914	Store and process data for a large set of Δ47 analyses,
2915	usually comprising more than one analytical session.
2916	'''
2917
2918	Nominal_D4x = {
2919		'ETH-1':   0.2052,
2920		'ETH-2':   0.2085,
2921		'ETH-3':   0.6132,
2922		'ETH-4':   0.4511,
2923		'IAEA-C1': 0.3018,
2924		'IAEA-C2': 0.6409,
2925		'MERCK':   0.5135,
2926		} # I-CDES (Bernasconi et al., 2021)
2927	'''
2928	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
2929	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
2930	reference frame.
2931
2932	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
2933	```py
2934	{
2935		'ETH-1'   : 0.2052,
2936		'ETH-2'   : 0.2085,
2937		'ETH-3'   : 0.6132,
2938		'ETH-4'   : 0.4511,
2939		'IAEA-C1' : 0.3018,
2940		'IAEA-C2' : 0.6409,
2941		'MERCK'   : 0.5135,
2942	}
2943	```
2944	'''
2945
2946
2947	@property
2948	def Nominal_D47(self):
2949		return self.Nominal_D4x
2950	
2951
2952	@Nominal_D47.setter
2953	def Nominal_D47(self, new):
2954		self.Nominal_D4x = dict(**new)
2955		self.refresh()
2956
2957
2958	def __init__(self, l = [], **kwargs):
2959		'''
2960		**Parameters:** same as `D4xdata.__init__()`
2961		'''
2962		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
2963
2964
2965	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
2966		'''
2967		Find all samples for which `Teq` is specified, compute equilibrium Δ47
2968		value for that temperature, and add treat these samples as additional anchors.
2969
2970		**Parameters**
2971
2972		+ `fCo2eqD47`: Which CO2 equilibrium law to use
2973		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
2974		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
2975		+ `priority`: if `replace`: forget old anchors and only use the new ones;
2976		if `new`: keep pre-existing anchors but update them in case of conflict
2977		between old and new Δ47 values;
2978		if `old`: keep pre-existing anchors but preserve their original Δ47
2979		values in case of conflict.
2980		'''
2981		f = {
2982			'petersen': fCO2eqD47_Petersen,
2983			'wang': fCO2eqD47_Wang,
2984			}[fCo2eqD47]
2985		foo = {}
2986		for r in self:
2987			if 'Teq' in r:
2988				if r['Sample'] in foo:
2989					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
2990				else:
2991					foo[r['Sample']] = f(r['Teq'])
2992			else:
2993					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
2994
2995		if priority == 'replace':
2996			self.Nominal_D47 = {}
2997		for s in foo:
2998			if priority != 'old' or s not in self.Nominal_D47:
2999				self.Nominal_D47[s] = foo[s]
3000	
3001
3002
3003
3004class D48data(D4xdata):
3005	'''
3006	Store and process data for a large set of Δ48 analyses,
3007	usually comprising more than one analytical session.
3008	'''
3009
3010	Nominal_D4x = {
3011		'ETH-1':  0.138,
3012		'ETH-2':  0.138,
3013		'ETH-3':  0.270,
3014		'ETH-4':  0.223,
3015		'GU-1':  -0.419,
3016		} # (Fiebig et al., 2019, 2021)
3017	'''
3018	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
3019	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
3020	reference frame.
3021
3022	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
3023	Fiebig et al. (in press)):
3024
3025	```py
3026	{
3027		'ETH-1' :  0.138,
3028		'ETH-2' :  0.138,
3029		'ETH-3' :  0.270,
3030		'ETH-4' :  0.223,
3031		'GU-1'  : -0.419,
3032	}
3033	```
3034	'''
3035
3036
3037	@property
3038	def Nominal_D48(self):
3039		return self.Nominal_D4x
3040
3041	
3042	@Nominal_D48.setter
3043	def Nominal_D48(self, new):
3044		self.Nominal_D4x = dict(**new)
3045		self.refresh()
3046
3047
3048	def __init__(self, l = [], **kwargs):
3049		'''
3050		**Parameters:** same as `D4xdata.__init__()`
3051		'''
3052		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
3053
3054
3055class _SessionPlot():
3056	'''
3057	Simple placeholder class
3058	'''
3059	def __init__(self):
3060		pass
>>>>>>> master
def fCO2eqD47_Petersen(T):
<<<<<<< HEAD
61def fCO2eqD47_Petersen(T):
62	'''
63	CO2 equilibrium Δ47 value as a function of T (in degrees C)
64	according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127).
65
66	'''
67	return float(_fCO2eqD47_Petersen(T))
=======
            
63def fCO2eqD47_Petersen(T):
64	'''
65	CO2 equilibrium Δ47 value as a function of T (in degrees C)
66	according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127).
67
68	'''
69	return float(_fCO2eqD47_Petersen(T))
>>>>>>> master

CO2 equilibrium Δ47 value as a function of T (in degrees C) according to Petersen et al. (2019).

def fCO2eqD47_Wang(T):
<<<<<<< HEAD
72def fCO2eqD47_Wang(T):
73	'''
74	CO2 equilibrium Δ47 value as a function of `T` (in degrees C)
75	according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039)
76	(supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)).
77	'''
78	return float(_fCO2eqD47_Wang(T))
=======
            
74def fCO2eqD47_Wang(T):
75	'''
76	CO2 equilibrium Δ47 value as a function of `T` (in degrees C)
77	according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039)
78	(supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)).
79	'''
80	return float(_fCO2eqD47_Wang(T))
>>>>>>> master

CO2 equilibrium Δ47 value as a function of T (in degrees C) according to Wang et al. (2004) (supplementary data of Dennis et al., 2011).

def correlated_sum(X, C, w=None):
<<<<<<< HEAD
81def correlated_sum(X, C, w = None):
82	'''
83	Compute covariance-aware linear combinations
84
85	**Parameters**
86	
87	+ `X`: list or 1-D array of values to sum
88	+ `C`: covariance matrix for the elements of `X`
89	+ `w`: list or 1-D array of weights to apply to the elements of `X`
90	       (all equal to 1 by default)
91
92	Return the sum (and its SE) of the elements of `X`, with optional weights equal
93	to the elements of `w`, accounting for covariances between the elements of `X`.
94	'''
95	if w is None:
96		w = [1 for x in X]
97	return np.dot(w,X), (np.dot(w,np.dot(C,w)))**.5
=======
            
83def correlated_sum(X, C, w = None):
84	'''
85	Compute covariance-aware linear combinations
86
87	**Parameters**
88	
89	+ `X`: list or 1-D array of values to sum
90	+ `C`: covariance matrix for the elements of `X`
91	+ `w`: list or 1-D array of weights to apply to the elements of `X`
92	       (all equal to 1 by default)
93
94	Return the sum (and its SE) of the elements of `X`, with optional weights equal
95	to the elements of `w`, accounting for covariances between the elements of `X`.
96	'''
97	if w is None:
98		w = [1 for x in X]
99	return np.dot(w,X), (np.dot(w,np.dot(C,w)))**.5
>>>>>>> master

Compute covariance-aware linear combinations

Parameters

  • X: list or 1-D array of values to sum
  • C: covariance matrix for the elements of X
  • w: list or 1-D array of weights to apply to the elements of X (all equal to 1 by default)

Return the sum (and its SE) of the elements of X, with optional weights equal to the elements of w, accounting for covariances between the elements of X.

def make_csv(x, hsep=',', vsep='\n'): <<<<<<< HEAD
100def make_csv(x, hsep = ',', vsep = '\n'):
101	'''
102	Formats a list of lists of strings as a CSV
103
104	**Parameters**
105
106	+ `x`: the list of lists of strings to format
107	+ `hsep`: the field separator (`,` by default)
108	+ `vsep`: the line-ending convention to use (`\\n` by default)
109
110	**Example**
111
112	```py
113	print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']]))
114	```
115
116	outputs:
117
118	```py
119	a,b,c
120	d,e,f
121	```
122	'''
123	return vsep.join([hsep.join(l) for l in x])
=======

                

    
102def make_csv(x, hsep = ',', vsep = '\n'):
103	'''
104	Formats a list of lists of strings as a CSV
105
106	**Parameters**
107
108	+ `x`: the list of lists of strings to format
109	+ `hsep`: the field separator (`,` by default)
110	+ `vsep`: the line-ending convention to use (`\\n` by default)
111
112	**Example**
113
114	```py
115	print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']]))
116	```
117
118	outputs:
119
120	```py
121	a,b,c
122	d,e,f
123	```
124	'''
125	return vsep.join([hsep.join(l) for l in x])
>>>>>>> master

Formats a list of lists of strings as a CSV

Parameters

  • x: the list of lists of strings to format
  • hsep: the field separator (, by default)
  • vsep: the line-ending convention to use (\n by default)

Example

print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']]))

outputs:

a,b,c
d,e,f
def pf(txt):
<<<<<<< HEAD
126def pf(txt):
127	'''
128	Modify string `txt` to follow `lmfit.Parameter()` naming rules.
129	'''
130	return txt.replace('-','_').replace('.','_').replace(' ','_')
=======
            
128def pf(txt):
129	'''
130	Modify string `txt` to follow `lmfit.Parameter()` naming rules.
131	'''
132	return txt.replace('-','_').replace('.','_').replace(' ','_')
>>>>>>> master

Modify string txt to follow lmfit.Parameter() naming rules.

def smart_type(x):
<<<<<<< HEAD
133def smart_type(x):
134	'''
135	Tries to convert string `x` to a float if it includes a decimal point, or
136	to an integer if it does not. If both attempts fail, return the original
137	string unchanged.
138	'''
139	try:
140		y = float(x)
141	except ValueError:
142		return x
143	if '.' not in x:
144		return int(y)
145	return y
=======
            
135def smart_type(x):
136	'''
137	Tries to convert string `x` to a float if it includes a decimal point, or
138	to an integer if it does not. If both attempts fail, return the original
139	string unchanged.
140	'''
141	try:
142		y = float(x)
143	except ValueError:
144		return x
145	if '.' not in x:
146		return int(y)
147	return y
>>>>>>> master

Tries to convert string x to a float if it includes a decimal point, or to an integer if it does not. If both attempts fail, return the original string unchanged.

def pretty_table(x, header=1, hsep=' ', vsep='–', align='<'):
<<<<<<< HEAD
148def pretty_table(x, header = 1, hsep = '  ', vsep = '–', align = '<'):
149	'''
150	Reads a list of lists of strings and outputs an ascii table
151
152	**Parameters**
153
154	+ `x`: a list of lists of strings
155	+ `header`: the number of lines to treat as header lines
156	+ `hsep`: the horizontal separator between columns
157	+ `vsep`: the character to use as vertical separator
158	+ `align`: string of left (`<`) or right (`>`) alignment characters.
159
160	**Example**
161
162	```py
163	x = [['A', 'B', 'C'], ['1', '1.9999', 'foo'], ['10', 'x', 'bar']]
164	print(pretty_table(x))
165	```
166	yields:	
167	```
168	--  ------  ---
169	A        B    C
170	--  ------  ---
171	1   1.9999  foo
172	10       x  bar
173	--  ------  ---
174	```
175	
176	'''
177	txt = []
178	widths = [np.max([len(e) for e in c]) for c in zip(*x)]
179
180	if len(widths) > len(align):
181		align += '>' * (len(widths)-len(align))
182	sepline = hsep.join([vsep*w for w in widths])
183	txt += [sepline]
184	for k,l in enumerate(x):
185		if k and k == header:
186			txt += [sepline]
187		txt += [hsep.join([f'{e:{a}{w}}' for e, w, a in zip(l, widths, align)])]
188	txt += [sepline]
189	txt += ['']
190	return '\n'.join(txt)
=======
            
150def pretty_table(x, header = 1, hsep = '  ', vsep = '–', align = '<'):
151	'''
152	Reads a list of lists of strings and outputs an ascii table
153
154	**Parameters**
155
156	+ `x`: a list of lists of strings
157	+ `header`: the number of lines to treat as header lines
158	+ `hsep`: the horizontal separator between columns
159	+ `vsep`: the character to use as vertical separator
160	+ `align`: string of left (`<`) or right (`>`) alignment characters.
161
162	**Example**
163
164	```py
165	x = [['A', 'B', 'C'], ['1', '1.9999', 'foo'], ['10', 'x', 'bar']]
166	print(pretty_table(x))
167	```
168	yields:	
169	```
170	--  ------  ---
171	A        B    C
172	--  ------  ---
173	1   1.9999  foo
174	10       x  bar
175	--  ------  ---
176	```
177	
178	'''
179	txt = []
180	widths = [np.max([len(e) for e in c]) for c in zip(*x)]
181
182	if len(widths) > len(align):
183		align += '>' * (len(widths)-len(align))
184	sepline = hsep.join([vsep*w for w in widths])
185	txt += [sepline]
186	for k,l in enumerate(x):
187		if k and k == header:
188			txt += [sepline]
189		txt += [hsep.join([f'{e:{a}{w}}' for e, w, a in zip(l, widths, align)])]
190	txt += [sepline]
191	txt += ['']
192	return '\n'.join(txt)
>>>>>>> master

Reads a list of lists of strings and outputs an ascii table

Parameters

  • x: a list of lists of strings
  • header: the number of lines to treat as header lines
  • hsep: the horizontal separator between columns
  • vsep: the character to use as vertical separator
  • align: string of left (<) or right (>) alignment characters.

Example

x = [['A', 'B', 'C'], ['1', '1.9999', 'foo'], ['10', 'x', 'bar']]
print(pretty_table(x))

yields:

--  ------  ---
A        B    C
--  ------  ---
1   1.9999  foo
10       x  bar
--  ------  ---
def transpose_table(x): <<<<<<< HEAD
193def transpose_table(x):
194	'''
195	Transpose a list if lists
196
197	**Parameters**
198
199	+ `x`: a list of lists
200
201	**Example**
202
203	```py
204	x = [[1, 2], [3, 4]]
205	print(transpose_table(x)) # yields: [[1, 3], [2, 4]]
206	```
207	'''
208	return [[e for e in c] for c in zip(*x)]
=======

                

    
195def transpose_table(x):
196	'''
197	Transpose a list if lists
198
199	**Parameters**
200
201	+ `x`: a list of lists
202
203	**Example**
204
205	```py
206	x = [[1, 2], [3, 4]]
207	print(transpose_table(x)) # yields: [[1, 3], [2, 4]]
208	```
209	'''
210	return [[e for e in c] for c in zip(*x)]
>>>>>>> master

Transpose a list if lists

Parameters

  • x: a list of lists

Example

x = [[1, 2], [3, 4]]
print(transpose_table(x)) # yields: [[1, 3], [2, 4]]
def w_avg(X, sX):
<<<<<<< HEAD
211def w_avg(X, sX) :
212	'''
213	Compute variance-weighted average
214
215	Returns the value and SE of the weighted average of the elements of `X`,
216	with relative weights equal to their inverse variances (`1/sX**2`).
217
218	**Parameters**
219
220	+ `X`: array-like of elements to average
221	+ `sX`: array-like of the corresponding SE values
222
223	**Tip**
224
225	If `X` and `sX` are initially arranged as a list of `(x, sx)` doublets,
226	they may be rearranged using `zip()`:
227
228	```python
229	foo = [(0, 1), (1, 0.5), (2, 0.5)]
230	print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333)
231	```
232	'''
233	X = [ x for x in X ]
234	sX = [ sx for sx in sX ]
235	W = [ sx**-2 for sx in sX ]
236	W = [ w/sum(W) for w in W ]
237	Xavg = sum([ w*x for w,x in zip(W,X) ])
238	sXavg = sum([ w**2*sx**2 for w,sx in zip(W,sX) ])**.5
239	return Xavg, sXavg
=======
            
213def w_avg(X, sX) :
214	'''
215	Compute variance-weighted average
216
217	Returns the value and SE of the weighted average of the elements of `X`,
218	with relative weights equal to their inverse variances (`1/sX**2`).
219
220	**Parameters**
221
222	+ `X`: array-like of elements to average
223	+ `sX`: array-like of the corresponding SE values
224
225	**Tip**
226
227	If `X` and `sX` are initially arranged as a list of `(x, sx)` doublets,
228	they may be rearranged using `zip()`:
229
230	```python
231	foo = [(0, 1), (1, 0.5), (2, 0.5)]
232	print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333)
233	```
234	'''
235	X = [ x for x in X ]
236	sX = [ sx for sx in sX ]
237	W = [ sx**-2 for sx in sX ]
238	W = [ w/sum(W) for w in W ]
239	Xavg = sum([ w*x for w,x in zip(W,X) ])
240	sXavg = sum([ w**2*sx**2 for w,sx in zip(W,sX) ])**.5
241	return Xavg, sXavg
>>>>>>> master

Compute variance-weighted average

Returns the value and SE of the weighted average of the elements of X, with relative weights equal to their inverse variances (1/sX**2).

Parameters

  • X: array-like of elements to average
  • sX: array-like of the corresponding SE values

Tip

If X and sX are initially arranged as a list of (x, sx) doublets, they may be rearranged using zip():

foo = [(0, 1), (1, 0.5), (2, 0.5)]
print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333)
def read_csv(filename, sep=''):
<<<<<<< HEAD
242def read_csv(filename, sep = ''):
243	'''
244	Read contents of `filename` in csv format and return a list of dictionaries.
245
246	In the csv string, spaces before and after field separators (`','` by default)
247	are optional.
248
249	**Parameters**
250
251	+ `filename`: the csv file to read
252	+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
253	whichever appers most often in the contents of `filename`.
254	'''
255	with open(filename) as fid:
256		txt = fid.read()
257
258	if sep == '':
259		sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
260	txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
261	return [{k: smart_type(v) for k,v in zip(txt[0], l) if v} for l in txt[1:]]
=======
            
244def read_csv(filename, sep = ''):
245	'''
246	Read contents of `filename` in csv format and return a list of dictionaries.
247
248	In the csv string, spaces before and after field separators (`','` by default)
249	are optional.
250
251	**Parameters**
252
253	+ `filename`: the csv file to read
254	+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
255	whichever appers most often in the contents of `filename`.
256	'''
257	with open(filename) as fid:
258		txt = fid.read()
259
260	if sep == '':
261		sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
262	txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
263	return [{k: smart_type(v) for k,v in zip(txt[0], l) if v} for l in txt[1:]]
>>>>>>> master

Read contents of filename in csv format and return a list of dictionaries.

In the csv string, spaces before and after field separators (',' by default) are optional.

Parameters

  • filename: the csv file to read
  • sep: csv separator delimiting the fields. By default, use ,, ;, or , whichever appers most often in the contents of filename.
def simulate_single_analysis( sample='MYSAMPLE', d13Cwg_VPDB=-4.0, d18Owg_VSMOW=26.0, d13C_VPDB=None, d18O_VPDB=None, D47=None, D48=None, D49=0.0, D17O=0.0, a47=1.0, b47=0.0, c47=-0.9, a48=1.0, b48=0.0, c48=-0.45, Nominal_D47=None, Nominal_D48=None, Nominal_d13C_VPDB=None, Nominal_d18O_VPDB=None, ALPHA_18O_ACID_REACTION=None, R13_VPDB=None, R17_VSMOW=None, R18_VSMOW=None, LAMBDA_17=None, R18_VPDB=None):
<<<<<<< HEAD
264def simulate_single_analysis(
265	sample = 'MYSAMPLE',
266	d13Cwg_VPDB = -4., d18Owg_VSMOW = 26.,
267	d13C_VPDB = None, d18O_VPDB = None,
268	D47 = None, D48 = None, D49 = 0., D17O = 0.,
269	a47 = 1., b47 = 0., c47 = -0.9,
270	a48 = 1., b48 = 0., c48 = -0.45,
271	Nominal_D47 = None,
272	Nominal_D48 = None,
273	Nominal_d13C_VPDB = None,
274	Nominal_d18O_VPDB = None,
275	ALPHA_18O_ACID_REACTION = None,
276	R13_VPDB = None,
277	R17_VSMOW = None,
278	R18_VSMOW = None,
279	LAMBDA_17 = None,
280	R18_VPDB = None,
281	):
282	'''
283	Compute working-gas delta values for a single analysis, assuming a stochastic working
284	gas and a “perfect” measurement (i.e. raw Δ values are identical to absolute values).
285	
286	**Parameters**
287
288	+ `sample`: sample name
289	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
290		(respectively –4 and +26 ‰ by default)
291	+ `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
292	+ `D47`, `D48`, `D49`, `D17O`: clumped-isotope and oxygen-17 anomalies
293		of the carbonate sample
294	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and
295		Δ48 values if `D47` or `D48` are not specified
296	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
297		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
298	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
299	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
300		correction parameters (by default equal to the `D4xdata` default values)
301	
302	Returns a dictionary with fields
303	`['Sample', 'D17O', 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'd45', 'd46', 'd47', 'd48', 'd49']`.
304	'''
305
306	if Nominal_d13C_VPDB is None:
307		Nominal_d13C_VPDB = D4xdata().Nominal_d13C_VPDB
308
309	if Nominal_d18O_VPDB is None:
310		Nominal_d18O_VPDB = D4xdata().Nominal_d18O_VPDB
311
312	if ALPHA_18O_ACID_REACTION is None:
313		ALPHA_18O_ACID_REACTION = D4xdata().ALPHA_18O_ACID_REACTION
314
315	if R13_VPDB is None:
316		R13_VPDB = D4xdata().R13_VPDB
317
318	if R17_VSMOW is None:
319		R17_VSMOW = D4xdata().R17_VSMOW
320
321	if R18_VSMOW is None:
322		R18_VSMOW = D4xdata().R18_VSMOW
323
324	if LAMBDA_17 is None:
325		LAMBDA_17 = D4xdata().LAMBDA_17
326
327	if R18_VPDB is None:
328		R18_VPDB = D4xdata().R18_VPDB
329	
330	R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW) ** LAMBDA_17
331	
332	if Nominal_D47 is None:
333		Nominal_D47 = D47data().Nominal_D47
334
335	if Nominal_D48 is None:
336		Nominal_D48 = D48data().Nominal_D48
337	
338	if d13C_VPDB is None:
339		if sample in Nominal_d13C_VPDB:
340			d13C_VPDB = Nominal_d13C_VPDB[sample]
341		else:
342			raise KeyError(f"Sample {sample} is missing d13C_VDP value, and it is not defined in Nominal_d13C_VDP.")
343
344	if d18O_VPDB is None:
345		if sample in Nominal_d18O_VPDB:
346			d18O_VPDB = Nominal_d18O_VPDB[sample]
347		else:
348			raise KeyError(f"Sample {sample} is missing d18O_VPDB value, and it is not defined in Nominal_d18O_VPDB.")
349
350	if D47 is None:
351		if sample in Nominal_D47:
352			D47 = Nominal_D47[sample]
353		else:
354			raise KeyError(f"Sample {sample} is missing D47 value, and it is not defined in Nominal_D47.")
355
356	if D48 is None:
357		if sample in Nominal_D48:
358			D48 = Nominal_D48[sample]
359		else:
360			raise KeyError(f"Sample {sample} is missing D48 value, and it is not defined in Nominal_D48.")
361
362	X = D4xdata()
363	X.R13_VPDB = R13_VPDB
364	X.R17_VSMOW = R17_VSMOW
365	X.R18_VSMOW = R18_VSMOW
366	X.LAMBDA_17 = LAMBDA_17
367	X.R18_VPDB = R18_VPDB
368	X.R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW)**LAMBDA_17
369
370	R45wg, R46wg, R47wg, R48wg, R49wg = X.compute_isobar_ratios(
371		R13 = R13_VPDB * (1 + d13Cwg_VPDB/1000),
372		R18 = R18_VSMOW * (1 + d18Owg_VSMOW/1000),
373		)
374	R45, R46, R47, R48, R49 = X.compute_isobar_ratios(
375		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
376		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
377		D17O=D17O, D47=D47, D48=D48, D49=D49,
378		)
379	R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = X.compute_isobar_ratios(
380		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
381		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
382		D17O=D17O,
383		)
384	
385	d45 = 1000 * (R45/R45wg - 1)
386	d46 = 1000 * (R46/R46wg - 1)
387	d47 = 1000 * (R47/R47wg - 1)
388	d48 = 1000 * (R48/R48wg - 1)
389	d49 = 1000 * (R49/R49wg - 1)
390
391	for k in range(3): # dumb iteration to adjust for small changes in d47
392		R47raw = (1 + (a47 * D47 + b47 * d47 + c47)/1000) * R47stoch
393		R48raw = (1 + (a48 * D48 + b48 * d48 + c48)/1000) * R48stoch	
394		d47 = 1000 * (R47raw/R47wg - 1)
395		d48 = 1000 * (R48raw/R48wg - 1)
396
397	return dict(
398		Sample = sample,
399		D17O = D17O,
400		d13Cwg_VPDB = d13Cwg_VPDB,
401		d18Owg_VSMOW = d18Owg_VSMOW,
402		d45 = d45,
403		d46 = d46,
404		d47 = d47,
405		d48 = d48,
406		d49 = d49,
407		)
=======
            
266def simulate_single_analysis(
267	sample = 'MYSAMPLE',
268	d13Cwg_VPDB = -4., d18Owg_VSMOW = 26.,
269	d13C_VPDB = None, d18O_VPDB = None,
270	D47 = None, D48 = None, D49 = 0., D17O = 0.,
271	a47 = 1., b47 = 0., c47 = -0.9,
272	a48 = 1., b48 = 0., c48 = -0.45,
273	Nominal_D47 = None,
274	Nominal_D48 = None,
275	Nominal_d13C_VPDB = None,
276	Nominal_d18O_VPDB = None,
277	ALPHA_18O_ACID_REACTION = None,
278	R13_VPDB = None,
279	R17_VSMOW = None,
280	R18_VSMOW = None,
281	LAMBDA_17 = None,
282	R18_VPDB = None,
283	):
284	'''
285	Compute working-gas delta values for a single analysis, assuming a stochastic working
286	gas and a “perfect” measurement (i.e. raw Δ values are identical to absolute values).
287	
288	**Parameters**
289
290	+ `sample`: sample name
291	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
292		(respectively –4 and +26 ‰ by default)
293	+ `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
294	+ `D47`, `D48`, `D49`, `D17O`: clumped-isotope and oxygen-17 anomalies
295		of the carbonate sample
296	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and
297		Δ48 values if `D47` or `D48` are not specified
298	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
299		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
300	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
301	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
302		correction parameters (by default equal to the `D4xdata` default values)
303	
304	Returns a dictionary with fields
305	`['Sample', 'D17O', 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'd45', 'd46', 'd47', 'd48', 'd49']`.
306	'''
307
308	if Nominal_d13C_VPDB is None:
309		Nominal_d13C_VPDB = D4xdata().Nominal_d13C_VPDB
310
311	if Nominal_d18O_VPDB is None:
312		Nominal_d18O_VPDB = D4xdata().Nominal_d18O_VPDB
313
314	if ALPHA_18O_ACID_REACTION is None:
315		ALPHA_18O_ACID_REACTION = D4xdata().ALPHA_18O_ACID_REACTION
316
317	if R13_VPDB is None:
318		R13_VPDB = D4xdata().R13_VPDB
319
320	if R17_VSMOW is None:
321		R17_VSMOW = D4xdata().R17_VSMOW
322
323	if R18_VSMOW is None:
324		R18_VSMOW = D4xdata().R18_VSMOW
325
326	if LAMBDA_17 is None:
327		LAMBDA_17 = D4xdata().LAMBDA_17
328
329	if R18_VPDB is None:
330		R18_VPDB = D4xdata().R18_VPDB
331	
332	R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW) ** LAMBDA_17
333	
334	if Nominal_D47 is None:
335		Nominal_D47 = D47data().Nominal_D47
336
337	if Nominal_D48 is None:
338		Nominal_D48 = D48data().Nominal_D48
339	
340	if d13C_VPDB is None:
341		if sample in Nominal_d13C_VPDB:
342			d13C_VPDB = Nominal_d13C_VPDB[sample]
343		else:
344			raise KeyError(f"Sample {sample} is missing d13C_VDP value, and it is not defined in Nominal_d13C_VDP.")
345
346	if d18O_VPDB is None:
347		if sample in Nominal_d18O_VPDB:
348			d18O_VPDB = Nominal_d18O_VPDB[sample]
349		else:
350			raise KeyError(f"Sample {sample} is missing d18O_VPDB value, and it is not defined in Nominal_d18O_VPDB.")
351
352	if D47 is None:
353		if sample in Nominal_D47:
354			D47 = Nominal_D47[sample]
355		else:
356			raise KeyError(f"Sample {sample} is missing D47 value, and it is not defined in Nominal_D47.")
357
358	if D48 is None:
359		if sample in Nominal_D48:
360			D48 = Nominal_D48[sample]
361		else:
362			raise KeyError(f"Sample {sample} is missing D48 value, and it is not defined in Nominal_D48.")
363
364	X = D4xdata()
365	X.R13_VPDB = R13_VPDB
366	X.R17_VSMOW = R17_VSMOW
367	X.R18_VSMOW = R18_VSMOW
368	X.LAMBDA_17 = LAMBDA_17
369	X.R18_VPDB = R18_VPDB
370	X.R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW)**LAMBDA_17
371
372	R45wg, R46wg, R47wg, R48wg, R49wg = X.compute_isobar_ratios(
373		R13 = R13_VPDB * (1 + d13Cwg_VPDB/1000),
374		R18 = R18_VSMOW * (1 + d18Owg_VSMOW/1000),
375		)
376	R45, R46, R47, R48, R49 = X.compute_isobar_ratios(
377		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
378		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
379		D17O=D17O, D47=D47, D48=D48, D49=D49,
380		)
381	R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = X.compute_isobar_ratios(
382		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
383		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
384		D17O=D17O,
385		)
386	
387	d45 = 1000 * (R45/R45wg - 1)
388	d46 = 1000 * (R46/R46wg - 1)
389	d47 = 1000 * (R47/R47wg - 1)
390	d48 = 1000 * (R48/R48wg - 1)
391	d49 = 1000 * (R49/R49wg - 1)
392
393	for k in range(3): # dumb iteration to adjust for small changes in d47
394		R47raw = (1 + (a47 * D47 + b47 * d47 + c47)/1000) * R47stoch
395		R48raw = (1 + (a48 * D48 + b48 * d48 + c48)/1000) * R48stoch	
396		d47 = 1000 * (R47raw/R47wg - 1)
397		d48 = 1000 * (R48raw/R48wg - 1)
398
399	return dict(
400		Sample = sample,
401		D17O = D17O,
402		d13Cwg_VPDB = d13Cwg_VPDB,
403		d18Owg_VSMOW = d18Owg_VSMOW,
404		d45 = d45,
405		d46 = d46,
406		d47 = d47,
407		d48 = d48,
408		d49 = d49,
409		)
>>>>>>> master

Compute working-gas delta values for a single analysis, assuming a stochastic working gas and a “perfect” measurement (i.e. raw Δ values are identical to absolute values).

Parameters

  • sample: sample name
  • d13Cwg_VPDB, d18Owg_VSMOW: bulk composition of the working gas (respectively –4 and +26 ‰ by default)
  • d13C_VPDB, d18O_VPDB: bulk composition of the carbonate sample
  • D47, D48, D49, D17O: clumped-isotope and oxygen-17 anomalies of the carbonate sample
  • Nominal_D47, Nominal_D48: where to lookup Δ47 and Δ48 values if D47 or D48 are not specified
  • Nominal_d13C_VPDB, Nominal_d18O_VPDB: where to lookup δ13C and δ18O values if d13C_VPDB or d18O_VPDB are not specified
  • ALPHA_18O_ACID_REACTION: 18O/16O acid fractionation factor
  • R13_VPDB, R17_VSMOW, R18_VSMOW, LAMBDA_17, R18_VPDB: oxygen-17 correction parameters (by default equal to the D4xdata default values)

Returns a dictionary with fields ['Sample', 'D17O', 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'd45', 'd46', 'd47', 'd48', 'd49'].

def virtual_data( samples=[], a47=1.0, b47=0.0, c47=-0.9, a48=1.0, b48=0.0, c48=-0.45, rD47=0.015, rD48=0.045, d13Cwg_VPDB=None, d18Owg_VSMOW=None, session=None, Nominal_D47=None, Nominal_D48=None, Nominal_d13C_VPDB=None, Nominal_d18O_VPDB=None, ALPHA_18O_ACID_REACTION=None, R13_VPDB=None, R17_VSMOW=None, R18_VSMOW=None, LAMBDA_17=None, R18_VPDB=None, seed=0):
<<<<<<< HEAD
410def virtual_data(
411	samples = [],
412	a47 = 1., b47 = 0., c47 = -0.9,
413	a48 = 1., b48 = 0., c48 = -0.45,
414	rD47 = 0.015, rD48 = 0.045,
415	d13Cwg_VPDB = None, d18Owg_VSMOW = None,
416	session = None,
417	Nominal_D47 = None, Nominal_D48 = None,
418	Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None,
419	ALPHA_18O_ACID_REACTION = None,
420	R13_VPDB = None,
421	R17_VSMOW = None,
422	R18_VSMOW = None,
423	LAMBDA_17 = None,
424	R18_VPDB = None,
425	seed = 0,
426	):
427	'''
428	Return list with simulated analyses from a single session.
429	
430	**Parameters**
431	
432	+ `samples`: a list of entries; each entry is a dictionary with the following fields:
433	    * `Sample`: the name of the sample
434	    * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
435	    * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
436	    * `N`: how many analyses to generate for this sample
437	+ `a47`: scrambling factor for Δ47
438	+ `b47`: compositional nonlinearity for Δ47
439	+ `c47`: working gas offset for Δ47
440	+ `a48`: scrambling factor for Δ48
441	+ `b48`: compositional nonlinearity for Δ48
442	+ `c48`: working gas offset for Δ48
443	+ `rD47`: analytical repeatability of Δ47
444	+ `rD48`: analytical repeatability of Δ48
445	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
446		(by default equal to the `simulate_single_analysis` default values)
447	+ `session`: name of the session (no name by default)
448	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values
449		if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
450	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
451		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified 
452		(by default equal to the `simulate_single_analysis` defaults)
453	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
454		(by default equal to the `simulate_single_analysis` defaults)
455	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
456		correction parameters (by default equal to the `simulate_single_analysis` default)
457	+ `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations
458	
459		
460	Here is an example of using this method to generate an arbitrary combination of
461	anchors and unknowns for a bunch of sessions:
462
463	```py
464	args = dict(
465		samples = [
466			dict(Sample = 'ETH-1', N = 4),
467			dict(Sample = 'ETH-2', N = 5),
468			dict(Sample = 'ETH-3', N = 6),
469			dict(Sample = 'FOO', N = 2,
470				d13C_VPDB = -5., d18O_VPDB = -10.,
471				D47 = 0.3, D48 = 0.15),
472			], rD47 = 0.010, rD48 = 0.030)
473
474	session1 = virtual_data(session = 'Session_01', **args, seed = 123)
475	session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
476	session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
477	session4 = virtual_data(session = 'Session_04', **args, seed = 123456)
478
479	D = D47data(session1 + session2 + session3 + session4)
480
481	D.crunch()
482	D.standardize()
483
484	D.table_of_sessions(verbose = True, save_to_file = False)
485	D.table_of_samples(verbose = True, save_to_file = False)
486	D.table_of_analyses(verbose = True, save_to_file = False)
487	```
488	
489	This should output something like:
490	
491	```
492	[table_of_sessions] 
493	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
494	Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47         a ± SE    1e3 x b ± SE          c ± SE
495	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
496	Session_01  15   2       -4.000        26.000  0.0000  0.0000  0.0110  0.997 ± 0.017  -0.097 ± 0.244  -0.896 ± 0.006
497	Session_02  15   2       -4.000        26.000  0.0000  0.0000  0.0109  1.002 ± 0.017  -0.110 ± 0.244  -0.901 ± 0.006
498	Session_03  15   2       -4.000        26.000  0.0000  0.0000  0.0107  1.010 ± 0.017  -0.037 ± 0.244  -0.904 ± 0.006
499	Session_04  15   2       -4.000        26.000  0.0000  0.0000  0.0106  1.001 ± 0.017  -0.181 ± 0.244  -0.894 ± 0.006
500	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
501
502	[table_of_samples] 
503	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
504	Sample   N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
505	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
506	ETH-1   16       2.02       37.02  0.2052                    0.0079          
507	ETH-2   20     -10.17       19.88  0.2085                    0.0100          
508	ETH-3   24       1.71       37.45  0.6132                    0.0105          
509	FOO      8      -5.00       28.91  0.2989  0.0040  ± 0.0080  0.0101     0.638
510	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
511
512	[table_of_analyses] 
513	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
514	UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
515	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
516	1    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.122986   21.273526   27.780042    2.020000   37.024281  -0.706013  -0.328878  -0.000013  0.192554
517	2    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.130144   21.282615   27.780042    2.020000   37.024281  -0.698974  -0.319981  -0.000013  0.199615
518	3    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.149219   21.299572   27.780042    2.020000   37.024281  -0.680215  -0.303383  -0.000013  0.218429
519	4    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.136616   21.233128   27.780042    2.020000   37.024281  -0.692609  -0.368421  -0.000013  0.205998
520	5    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.697171  -12.203054  -18.023381  -10.170000   19.875825  -0.680771  -0.290128  -0.000002  0.215054
521	6    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701124  -12.184422  -18.023381  -10.170000   19.875825  -0.684772  -0.271272  -0.000002  0.211041
522	7    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715105  -12.195251  -18.023381  -10.170000   19.875825  -0.698923  -0.282232  -0.000002  0.196848
523	8    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701529  -12.204963  -18.023381  -10.170000   19.875825  -0.685182  -0.292061  -0.000002  0.210630
524	9    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.711420  -12.228478  -18.023381  -10.170000   19.875825  -0.695193  -0.315859  -0.000002  0.200589
525	10   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.666719   22.296486   28.306614    1.710000   37.450394  -0.290459  -0.147284  -0.000014  0.609363
526	11   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.671553   22.291060   28.306614    1.710000   37.450394  -0.285706  -0.152592  -0.000014  0.614130
527	12   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.652854   22.273271   28.306614    1.710000   37.450394  -0.304093  -0.169990  -0.000014  0.595689
528	13   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.684168   22.263156   28.306614    1.710000   37.450394  -0.273302  -0.179883  -0.000014  0.626572
529	14   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.662702   22.253578   28.306614    1.710000   37.450394  -0.294409  -0.189251  -0.000014  0.605401
530	15   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.681957   22.230907   28.306614    1.710000   37.450394  -0.275476  -0.211424  -0.000014  0.624391
531	16   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.312044    5.395798    4.665655   -5.000000   28.907344  -0.598436  -0.268176  -0.000006  0.298996
532	17   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.328123    5.307086    4.665655   -5.000000   28.907344  -0.582387  -0.356389  -0.000006  0.315092
533	18   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.122201   21.340606   27.780042    2.020000   37.024281  -0.706785  -0.263217  -0.000013  0.195135
534	19   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.134868   21.305714   27.780042    2.020000   37.024281  -0.694328  -0.297370  -0.000013  0.207564
535	20   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.140008   21.261931   27.780042    2.020000   37.024281  -0.689273  -0.340227  -0.000013  0.212607
536	21   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.135540   21.298472   27.780042    2.020000   37.024281  -0.693667  -0.304459  -0.000013  0.208224
537	22   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701213  -12.202602  -18.023381  -10.170000   19.875825  -0.684862  -0.289671  -0.000002  0.213842
538	23   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.685649  -12.190405  -18.023381  -10.170000   19.875825  -0.669108  -0.277327  -0.000002  0.229559
539	24   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.719003  -12.257955  -18.023381  -10.170000   19.875825  -0.702869  -0.345692  -0.000002  0.195876
540	25   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700592  -12.204641  -18.023381  -10.170000   19.875825  -0.684233  -0.291735  -0.000002  0.214469
541	26   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720426  -12.214561  -18.023381  -10.170000   19.875825  -0.704308  -0.301774  -0.000002  0.194439
542	27   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.673044   22.262090   28.306614    1.710000   37.450394  -0.284240  -0.180926  -0.000014  0.616730
543	28   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.666542   22.263401   28.306614    1.710000   37.450394  -0.290634  -0.179643  -0.000014  0.610350
544	29   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.680487   22.243486   28.306614    1.710000   37.450394  -0.276921  -0.199121  -0.000014  0.624031
545	30   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.663900   22.245175   28.306614    1.710000   37.450394  -0.293231  -0.197469  -0.000014  0.607759
546	31   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.674379   22.301309   28.306614    1.710000   37.450394  -0.282927  -0.142568  -0.000014  0.618039
547	32   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.660825   22.270466   28.306614    1.710000   37.450394  -0.296255  -0.172733  -0.000014  0.604742
548	33   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.294076    5.349940    4.665655   -5.000000   28.907344  -0.616369  -0.313776  -0.000006  0.283707
549	34   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.313775    5.292121    4.665655   -5.000000   28.907344  -0.596708  -0.371269  -0.000006  0.303323
550	35   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.121613   21.259909   27.780042    2.020000   37.024281  -0.707364  -0.342207  -0.000013  0.194934
551	36   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.145714   21.304889   27.780042    2.020000   37.024281  -0.683661  -0.298178  -0.000013  0.218401
552	37   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.126573   21.325093   27.780042    2.020000   37.024281  -0.702485  -0.278401  -0.000013  0.199764
553	38   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.132057   21.323211   27.780042    2.020000   37.024281  -0.697092  -0.280244  -0.000013  0.205104
554	39   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.708448  -12.232023  -18.023381  -10.170000   19.875825  -0.692185  -0.319447  -0.000002  0.208915
555	40   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.714417  -12.202504  -18.023381  -10.170000   19.875825  -0.698226  -0.289572  -0.000002  0.202934
556	41   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720039  -12.264469  -18.023381  -10.170000   19.875825  -0.703917  -0.352285  -0.000002  0.197300
557	42   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701953  -12.228550  -18.023381  -10.170000   19.875825  -0.685611  -0.315932  -0.000002  0.215423
558	43   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.704535  -12.213634  -18.023381  -10.170000   19.875825  -0.688224  -0.300836  -0.000002  0.212837
559	44   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.652920   22.230043   28.306614    1.710000   37.450394  -0.304028  -0.212269  -0.000014  0.594265
560	45   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.691485   22.261017   28.306614    1.710000   37.450394  -0.266106  -0.181975  -0.000014  0.631810
561	46   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.679119   22.305357   28.306614    1.710000   37.450394  -0.278266  -0.138609  -0.000014  0.619771
562	47   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.663623   22.327286   28.306614    1.710000   37.450394  -0.293503  -0.117161  -0.000014  0.604685
563	48   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.678524   22.282103   28.306614    1.710000   37.450394  -0.278851  -0.161352  -0.000014  0.619192
564	49   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.666246   22.283361   28.306614    1.710000   37.450394  -0.290925  -0.160121  -0.000014  0.607238
565	50   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.309929    5.340249    4.665655   -5.000000   28.907344  -0.600546  -0.323413  -0.000006  0.300148
566	51   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.317548    5.334102    4.665655   -5.000000   28.907344  -0.592942  -0.329524  -0.000006  0.307676
567	52   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.136865   21.300298   27.780042    2.020000   37.024281  -0.692364  -0.302672  -0.000013  0.204033
568	53   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.133538   21.291260   27.780042    2.020000   37.024281  -0.695637  -0.311519  -0.000013  0.200762
569	54   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.139991   21.319865   27.780042    2.020000   37.024281  -0.689290  -0.283519  -0.000013  0.207107
570	55   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.145748   21.330075   27.780042    2.020000   37.024281  -0.683629  -0.273524  -0.000013  0.212766
571	56   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702989  -12.202762  -18.023381  -10.170000   19.875825  -0.686660  -0.289833  -0.000002  0.204507
572	57   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.692830  -12.240287  -18.023381  -10.170000   19.875825  -0.676377  -0.327811  -0.000002  0.214786
573	58   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702899  -12.180291  -18.023381  -10.170000   19.875825  -0.686568  -0.267091  -0.000002  0.204598
574	59   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.709282  -12.282257  -18.023381  -10.170000   19.875825  -0.693029  -0.370287  -0.000002  0.198140
575	60   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.679330  -12.235994  -18.023381  -10.170000   19.875825  -0.662712  -0.323466  -0.000002  0.228446
576	61   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.695594   22.238663   28.306614    1.710000   37.450394  -0.262066  -0.203838  -0.000014  0.634200
577	62   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.663504   22.286354   28.306614    1.710000   37.450394  -0.293620  -0.157194  -0.000014  0.602656
578	63   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666457   22.254290   28.306614    1.710000   37.450394  -0.290717  -0.188555  -0.000014  0.605558
579	64   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666910   22.223232   28.306614    1.710000   37.450394  -0.290271  -0.218930  -0.000014  0.606004
580	65   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.679662   22.257256   28.306614    1.710000   37.450394  -0.277732  -0.185653  -0.000014  0.618539
581	66   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.676768   22.267680   28.306614    1.710000   37.450394  -0.280578  -0.175459  -0.000014  0.615693
582	67   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.307663    5.317330    4.665655   -5.000000   28.907344  -0.602808  -0.346202  -0.000006  0.290853
583	68   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.308562    5.331400    4.665655   -5.000000   28.907344  -0.601911  -0.332212  -0.000006  0.291749
584	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
585	```
586	'''
587	
588	kwargs = locals().copy()
589
590	from numpy import random as nprandom
591	if seed:
592		rng = nprandom.default_rng(seed)
593	else:
594		rng = nprandom.default_rng()
595	
596	N = sum([s['N'] for s in samples])
597	errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
598	errors47 *= rD47 / stdev(errors47) # scale errors to rD47
599	errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
600	errors48 *= rD48 / stdev(errors48) # scale errors to rD48
601	
602	k = 0
603	out = []
604	for s in samples:
605		kw = {}
606		kw['sample'] = s['Sample']
607		kw = {
608			**kw,
609			**{var: kwargs[var]
610				for var in [
611					'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION',
612					'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB',
613					'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB',
614					'a47', 'b47', 'c47', 'a48', 'b48', 'c48',
615					]
616				if kwargs[var] is not None},
617			**{var: s[var]
618				for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O']
619				if var in s},
620			}
621
622		sN = s['N']
623		while sN:
624			out.append(simulate_single_analysis(**kw))
625			out[-1]['d47'] += errors47[k] * a47
626			out[-1]['d48'] += errors48[k] * a48
627			sN -= 1
628			k += 1
629
630		if session is not None:
631			for r in out:
632				r['Session'] = session
633	return out
=======
            
412def virtual_data(
413	samples = [],
414	a47 = 1., b47 = 0., c47 = -0.9,
415	a48 = 1., b48 = 0., c48 = -0.45,
416	rD47 = 0.015, rD48 = 0.045,
417	d13Cwg_VPDB = None, d18Owg_VSMOW = None,
418	session = None,
419	Nominal_D47 = None, Nominal_D48 = None,
420	Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None,
421	ALPHA_18O_ACID_REACTION = None,
422	R13_VPDB = None,
423	R17_VSMOW = None,
424	R18_VSMOW = None,
425	LAMBDA_17 = None,
426	R18_VPDB = None,
427	seed = 0,
428	):
429	'''
430	Return list with simulated analyses from a single session.
431	
432	**Parameters**
433	
434	+ `samples`: a list of entries; each entry is a dictionary with the following fields:
435	    * `Sample`: the name of the sample
436	    * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
437	    * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
438	    * `N`: how many analyses to generate for this sample
439	+ `a47`: scrambling factor for Δ47
440	+ `b47`: compositional nonlinearity for Δ47
441	+ `c47`: working gas offset for Δ47
442	+ `a48`: scrambling factor for Δ48
443	+ `b48`: compositional nonlinearity for Δ48
444	+ `c48`: working gas offset for Δ48
445	+ `rD47`: analytical repeatability of Δ47
446	+ `rD48`: analytical repeatability of Δ48
447	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
448		(by default equal to the `simulate_single_analysis` default values)
449	+ `session`: name of the session (no name by default)
450	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values
451		if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
452	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
453		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified 
454		(by default equal to the `simulate_single_analysis` defaults)
455	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
456		(by default equal to the `simulate_single_analysis` defaults)
457	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
458		correction parameters (by default equal to the `simulate_single_analysis` default)
459	+ `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations
460	
461		
462	Here is an example of using this method to generate an arbitrary combination of
463	anchors and unknowns for a bunch of sessions:
464
465	```py
466	args = dict(
467		samples = [
468			dict(Sample = 'ETH-1', N = 4),
469			dict(Sample = 'ETH-2', N = 5),
470			dict(Sample = 'ETH-3', N = 6),
471			dict(Sample = 'FOO', N = 2,
472				d13C_VPDB = -5., d18O_VPDB = -10.,
473				D47 = 0.3, D48 = 0.15),
474			], rD47 = 0.010, rD48 = 0.030)
475
476	session1 = virtual_data(session = 'Session_01', **args, seed = 123)
477	session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
478	session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
479	session4 = virtual_data(session = 'Session_04', **args, seed = 123456)
480
481	D = D47data(session1 + session2 + session3 + session4)
482
483	D.crunch()
484	D.standardize()
485
486	D.table_of_sessions(verbose = True, save_to_file = False)
487	D.table_of_samples(verbose = True, save_to_file = False)
488	D.table_of_analyses(verbose = True, save_to_file = False)
489	```
490	
491	This should output something like:
492	
493	```
494	[table_of_sessions] 
495	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
496	Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47         a ± SE    1e3 x b ± SE          c ± SE
497	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
498	Session_01  15   2       -4.000        26.000  0.0000  0.0000  0.0110  0.997 ± 0.017  -0.097 ± 0.244  -0.896 ± 0.006
499	Session_02  15   2       -4.000        26.000  0.0000  0.0000  0.0109  1.002 ± 0.017  -0.110 ± 0.244  -0.901 ± 0.006
500	Session_03  15   2       -4.000        26.000  0.0000  0.0000  0.0107  1.010 ± 0.017  -0.037 ± 0.244  -0.904 ± 0.006
501	Session_04  15   2       -4.000        26.000  0.0000  0.0000  0.0106  1.001 ± 0.017  -0.181 ± 0.244  -0.894 ± 0.006
502	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
503
504	[table_of_samples] 
505	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
506	Sample   N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
507	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
508	ETH-1   16       2.02       37.02  0.2052                    0.0079          
509	ETH-2   20     -10.17       19.88  0.2085                    0.0100          
510	ETH-3   24       1.71       37.45  0.6132                    0.0105          
511	FOO      8      -5.00       28.91  0.2989  0.0040  ± 0.0080  0.0101     0.638
512	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
513
514	[table_of_analyses] 
515	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
516	UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
517	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
518	1    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.122986   21.273526   27.780042    2.020000   37.024281  -0.706013  -0.328878  -0.000013  0.192554
519	2    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.130144   21.282615   27.780042    2.020000   37.024281  -0.698974  -0.319981  -0.000013  0.199615
520	3    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.149219   21.299572   27.780042    2.020000   37.024281  -0.680215  -0.303383  -0.000013  0.218429
521	4    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.136616   21.233128   27.780042    2.020000   37.024281  -0.692609  -0.368421  -0.000013  0.205998
522	5    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.697171  -12.203054  -18.023381  -10.170000   19.875825  -0.680771  -0.290128  -0.000002  0.215054
523	6    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701124  -12.184422  -18.023381  -10.170000   19.875825  -0.684772  -0.271272  -0.000002  0.211041
524	7    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715105  -12.195251  -18.023381  -10.170000   19.875825  -0.698923  -0.282232  -0.000002  0.196848
525	8    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701529  -12.204963  -18.023381  -10.170000   19.875825  -0.685182  -0.292061  -0.000002  0.210630
526	9    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.711420  -12.228478  -18.023381  -10.170000   19.875825  -0.695193  -0.315859  -0.000002  0.200589
527	10   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.666719   22.296486   28.306614    1.710000   37.450394  -0.290459  -0.147284  -0.000014  0.609363
528	11   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.671553   22.291060   28.306614    1.710000   37.450394  -0.285706  -0.152592  -0.000014  0.614130
529	12   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.652854   22.273271   28.306614    1.710000   37.450394  -0.304093  -0.169990  -0.000014  0.595689
530	13   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.684168   22.263156   28.306614    1.710000   37.450394  -0.273302  -0.179883  -0.000014  0.626572
531	14   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.662702   22.253578   28.306614    1.710000   37.450394  -0.294409  -0.189251  -0.000014  0.605401
532	15   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.681957   22.230907   28.306614    1.710000   37.450394  -0.275476  -0.211424  -0.000014  0.624391
533	16   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.312044    5.395798    4.665655   -5.000000   28.907344  -0.598436  -0.268176  -0.000006  0.298996
534	17   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.328123    5.307086    4.665655   -5.000000   28.907344  -0.582387  -0.356389  -0.000006  0.315092
535	18   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.122201   21.340606   27.780042    2.020000   37.024281  -0.706785  -0.263217  -0.000013  0.195135
536	19   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.134868   21.305714   27.780042    2.020000   37.024281  -0.694328  -0.297370  -0.000013  0.207564
537	20   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.140008   21.261931   27.780042    2.020000   37.024281  -0.689273  -0.340227  -0.000013  0.212607
538	21   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.135540   21.298472   27.780042    2.020000   37.024281  -0.693667  -0.304459  -0.000013  0.208224
539	22   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701213  -12.202602  -18.023381  -10.170000   19.875825  -0.684862  -0.289671  -0.000002  0.213842
540	23   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.685649  -12.190405  -18.023381  -10.170000   19.875825  -0.669108  -0.277327  -0.000002  0.229559
541	24   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.719003  -12.257955  -18.023381  -10.170000   19.875825  -0.702869  -0.345692  -0.000002  0.195876
542	25   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700592  -12.204641  -18.023381  -10.170000   19.875825  -0.684233  -0.291735  -0.000002  0.214469
543	26   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720426  -12.214561  -18.023381  -10.170000   19.875825  -0.704308  -0.301774  -0.000002  0.194439
544	27   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.673044   22.262090   28.306614    1.710000   37.450394  -0.284240  -0.180926  -0.000014  0.616730
545	28   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.666542   22.263401   28.306614    1.710000   37.450394  -0.290634  -0.179643  -0.000014  0.610350
546	29   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.680487   22.243486   28.306614    1.710000   37.450394  -0.276921  -0.199121  -0.000014  0.624031
547	30   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.663900   22.245175   28.306614    1.710000   37.450394  -0.293231  -0.197469  -0.000014  0.607759
548	31   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.674379   22.301309   28.306614    1.710000   37.450394  -0.282927  -0.142568  -0.000014  0.618039
549	32   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.660825   22.270466   28.306614    1.710000   37.450394  -0.296255  -0.172733  -0.000014  0.604742
550	33   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.294076    5.349940    4.665655   -5.000000   28.907344  -0.616369  -0.313776  -0.000006  0.283707
551	34   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.313775    5.292121    4.665655   -5.000000   28.907344  -0.596708  -0.371269  -0.000006  0.303323
552	35   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.121613   21.259909   27.780042    2.020000   37.024281  -0.707364  -0.342207  -0.000013  0.194934
553	36   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.145714   21.304889   27.780042    2.020000   37.024281  -0.683661  -0.298178  -0.000013  0.218401
554	37   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.126573   21.325093   27.780042    2.020000   37.024281  -0.702485  -0.278401  -0.000013  0.199764
555	38   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.132057   21.323211   27.780042    2.020000   37.024281  -0.697092  -0.280244  -0.000013  0.205104
556	39   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.708448  -12.232023  -18.023381  -10.170000   19.875825  -0.692185  -0.319447  -0.000002  0.208915
557	40   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.714417  -12.202504  -18.023381  -10.170000   19.875825  -0.698226  -0.289572  -0.000002  0.202934
558	41   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720039  -12.264469  -18.023381  -10.170000   19.875825  -0.703917  -0.352285  -0.000002  0.197300
559	42   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701953  -12.228550  -18.023381  -10.170000   19.875825  -0.685611  -0.315932  -0.000002  0.215423
560	43   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.704535  -12.213634  -18.023381  -10.170000   19.875825  -0.688224  -0.300836  -0.000002  0.212837
561	44   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.652920   22.230043   28.306614    1.710000   37.450394  -0.304028  -0.212269  -0.000014  0.594265
562	45   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.691485   22.261017   28.306614    1.710000   37.450394  -0.266106  -0.181975  -0.000014  0.631810
563	46   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.679119   22.305357   28.306614    1.710000   37.450394  -0.278266  -0.138609  -0.000014  0.619771
564	47   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.663623   22.327286   28.306614    1.710000   37.450394  -0.293503  -0.117161  -0.000014  0.604685
565	48   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.678524   22.282103   28.306614    1.710000   37.450394  -0.278851  -0.161352  -0.000014  0.619192
566	49   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.666246   22.283361   28.306614    1.710000   37.450394  -0.290925  -0.160121  -0.000014  0.607238
567	50   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.309929    5.340249    4.665655   -5.000000   28.907344  -0.600546  -0.323413  -0.000006  0.300148
568	51   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.317548    5.334102    4.665655   -5.000000   28.907344  -0.592942  -0.329524  -0.000006  0.307676
569	52   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.136865   21.300298   27.780042    2.020000   37.024281  -0.692364  -0.302672  -0.000013  0.204033
570	53   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.133538   21.291260   27.780042    2.020000   37.024281  -0.695637  -0.311519  -0.000013  0.200762
571	54   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.139991   21.319865   27.780042    2.020000   37.024281  -0.689290  -0.283519  -0.000013  0.207107
572	55   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.145748   21.330075   27.780042    2.020000   37.024281  -0.683629  -0.273524  -0.000013  0.212766
573	56   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702989  -12.202762  -18.023381  -10.170000   19.875825  -0.686660  -0.289833  -0.000002  0.204507
574	57   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.692830  -12.240287  -18.023381  -10.170000   19.875825  -0.676377  -0.327811  -0.000002  0.214786
575	58   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702899  -12.180291  -18.023381  -10.170000   19.875825  -0.686568  -0.267091  -0.000002  0.204598
576	59   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.709282  -12.282257  -18.023381  -10.170000   19.875825  -0.693029  -0.370287  -0.000002  0.198140
577	60   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.679330  -12.235994  -18.023381  -10.170000   19.875825  -0.662712  -0.323466  -0.000002  0.228446
578	61   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.695594   22.238663   28.306614    1.710000   37.450394  -0.262066  -0.203838  -0.000014  0.634200
579	62   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.663504   22.286354   28.306614    1.710000   37.450394  -0.293620  -0.157194  -0.000014  0.602656
580	63   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666457   22.254290   28.306614    1.710000   37.450394  -0.290717  -0.188555  -0.000014  0.605558
581	64   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666910   22.223232   28.306614    1.710000   37.450394  -0.290271  -0.218930  -0.000014  0.606004
582	65   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.679662   22.257256   28.306614    1.710000   37.450394  -0.277732  -0.185653  -0.000014  0.618539
583	66   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.676768   22.267680   28.306614    1.710000   37.450394  -0.280578  -0.175459  -0.000014  0.615693
584	67   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.307663    5.317330    4.665655   -5.000000   28.907344  -0.602808  -0.346202  -0.000006  0.290853
585	68   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.308562    5.331400    4.665655   -5.000000   28.907344  -0.601911  -0.332212  -0.000006  0.291749
586	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
587	```
588	'''
589	
590	kwargs = locals().copy()
591
592	from numpy import random as nprandom
593	if seed:
594		rng = nprandom.default_rng(seed)
595	else:
596		rng = nprandom.default_rng()
597	
598	N = sum([s['N'] for s in samples])
599	errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
600	errors47 *= rD47 / stdev(errors47) # scale errors to rD47
601	errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
602	errors48 *= rD48 / stdev(errors48) # scale errors to rD48
603	
604	k = 0
605	out = []
606	for s in samples:
607		kw = {}
608		kw['sample'] = s['Sample']
609		kw = {
610			**kw,
611			**{var: kwargs[var]
612				for var in [
613					'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION',
614					'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB',
615					'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB',
616					'a47', 'b47', 'c47', 'a48', 'b48', 'c48',
617					]
618				if kwargs[var] is not None},
619			**{var: s[var]
620				for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O']
621				if var in s},
622			}
623
624		sN = s['N']
625		while sN:
626			out.append(simulate_single_analysis(**kw))
627			out[-1]['d47'] += errors47[k] * a47
628			out[-1]['d48'] += errors48[k] * a48
629			sN -= 1
630			k += 1
631
632		if session is not None:
633			for r in out:
634				r['Session'] = session
635	return out
>>>>>>> master

Return list with simulated analyses from a single session.

Parameters

  • samples: a list of entries; each entry is a dictionary with the following fields:
    • Sample: the name of the sample
    • d13C_VPDB, d18O_VPDB: bulk composition of the carbonate sample
    • D47, D48, D49, D17O (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
    • N: how many analyses to generate for this sample
  • a47: scrambling factor for Δ47
  • b47: compositional nonlinearity for Δ47
  • c47: working gas offset for Δ47
  • a48: scrambling factor for Δ48
  • b48: compositional nonlinearity for Δ48
  • c48: working gas offset for Δ48
  • rD47: analytical repeatability of Δ47
  • rD48: analytical repeatability of Δ48
  • d13Cwg_VPDB, d18Owg_VSMOW: bulk composition of the working gas (by default equal to the simulate_single_analysis default values)
  • session: name of the session (no name by default)
  • Nominal_D47, Nominal_D48: where to lookup Δ47 and Δ48 values if D47 or D48 are not specified (by default equal to the simulate_single_analysis defaults)
  • Nominal_d13C_VPDB, Nominal_d18O_VPDB: where to lookup δ13C and δ18O values if d13C_VPDB or d18O_VPDB are not specified (by default equal to the simulate_single_analysis defaults)
  • ALPHA_18O_ACID_REACTION: 18O/16O acid fractionation factor (by default equal to the simulate_single_analysis defaults)
  • R13_VPDB, R17_VSMOW, R18_VSMOW, LAMBDA_17, R18_VPDB: oxygen-17 correction parameters (by default equal to the simulate_single_analysis default)
  • seed: explicitly set to a non-zero value to achieve random but repeatable simulations

Here is an example of using this method to generate an arbitrary combination of anchors and unknowns for a bunch of sessions:

args = dict(
        samples = [
                dict(Sample = 'ETH-1', N = 4),
                dict(Sample = 'ETH-2', N = 5),
                dict(Sample = 'ETH-3', N = 6),
                dict(Sample = 'FOO', N = 2,
                        d13C_VPDB = -5., d18O_VPDB = -10.,
                        D47 = 0.3, D48 = 0.15),
                ], rD47 = 0.010, rD48 = 0.030)

session1 = virtual_data(session = 'Session_01', **args, seed = 123)
session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
session4 = virtual_data(session = 'Session_04', **args, seed = 123456)

D = D47data(session1 + session2 + session3 + session4)

D.crunch()
D.standardize()

D.table_of_sessions(verbose = True, save_to_file = False)
D.table_of_samples(verbose = True, save_to_file = False)
D.table_of_analyses(verbose = True, save_to_file = False)

This should output something like:

[table_of_sessions] 
––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47         a ± SE    1e3 x b ± SE          c ± SE
––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
Session_01  15   2       -4.000        26.000  0.0000  0.0000  0.0110  0.997 ± 0.017  -0.097 ± 0.244  -0.896 ± 0.006
Session_02  15   2       -4.000        26.000  0.0000  0.0000  0.0109  1.002 ± 0.017  -0.110 ± 0.244  -0.901 ± 0.006
Session_03  15   2       -4.000        26.000  0.0000  0.0000  0.0107  1.010 ± 0.017  -0.037 ± 0.244  -0.904 ± 0.006
Session_04  15   2       -4.000        26.000  0.0000  0.0000  0.0106  1.001 ± 0.017  -0.181 ± 0.244  -0.894 ± 0.006
––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––

[table_of_samples] 
––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
Sample   N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
ETH-1   16       2.02       37.02  0.2052                    0.0079          
ETH-2   20     -10.17       19.88  0.2085                    0.0100          
ETH-3   24       1.71       37.45  0.6132                    0.0105          
FOO      8      -5.00       28.91  0.2989  0.0040  ± 0.0080  0.0101     0.638
––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––

[table_of_analyses] 
–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
1    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.122986   21.273526   27.780042    2.020000   37.024281  -0.706013  -0.328878  -0.000013  0.192554
2    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.130144   21.282615   27.780042    2.020000   37.024281  -0.698974  -0.319981  -0.000013  0.199615
3    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.149219   21.299572   27.780042    2.020000   37.024281  -0.680215  -0.303383  -0.000013  0.218429
4    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.136616   21.233128   27.780042    2.020000   37.024281  -0.692609  -0.368421  -0.000013  0.205998
5    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.697171  -12.203054  -18.023381  -10.170000   19.875825  -0.680771  -0.290128  -0.000002  0.215054
6    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701124  -12.184422  -18.023381  -10.170000   19.875825  -0.684772  -0.271272  -0.000002  0.211041
7    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715105  -12.195251  -18.023381  -10.170000   19.875825  -0.698923  -0.282232  -0.000002  0.196848
8    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701529  -12.204963  -18.023381  -10.170000   19.875825  -0.685182  -0.292061  -0.000002  0.210630
9    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.711420  -12.228478  -18.023381  -10.170000   19.875825  -0.695193  -0.315859  -0.000002  0.200589
10   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.666719   22.296486   28.306614    1.710000   37.450394  -0.290459  -0.147284  -0.000014  0.609363
11   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.671553   22.291060   28.306614    1.710000   37.450394  -0.285706  -0.152592  -0.000014  0.614130
12   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.652854   22.273271   28.306614    1.710000   37.450394  -0.304093  -0.169990  -0.000014  0.595689
13   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.684168   22.263156   28.306614    1.710000   37.450394  -0.273302  -0.179883  -0.000014  0.626572
14   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.662702   22.253578   28.306614    1.710000   37.450394  -0.294409  -0.189251  -0.000014  0.605401
15   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.681957   22.230907   28.306614    1.710000   37.450394  -0.275476  -0.211424  -0.000014  0.624391
16   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.312044    5.395798    4.665655   -5.000000   28.907344  -0.598436  -0.268176  -0.000006  0.298996
17   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.328123    5.307086    4.665655   -5.000000   28.907344  -0.582387  -0.356389  -0.000006  0.315092
18   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.122201   21.340606   27.780042    2.020000   37.024281  -0.706785  -0.263217  -0.000013  0.195135
19   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.134868   21.305714   27.780042    2.020000   37.024281  -0.694328  -0.297370  -0.000013  0.207564
20   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.140008   21.261931   27.780042    2.020000   37.024281  -0.689273  -0.340227  -0.000013  0.212607
21   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.135540   21.298472   27.780042    2.020000   37.024281  -0.693667  -0.304459  -0.000013  0.208224
22   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701213  -12.202602  -18.023381  -10.170000   19.875825  -0.684862  -0.289671  -0.000002  0.213842
23   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.685649  -12.190405  -18.023381  -10.170000   19.875825  -0.669108  -0.277327  -0.000002  0.229559
24   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.719003  -12.257955  -18.023381  -10.170000   19.875825  -0.702869  -0.345692  -0.000002  0.195876
25   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700592  -12.204641  -18.023381  -10.170000   19.875825  -0.684233  -0.291735  -0.000002  0.214469
26   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720426  -12.214561  -18.023381  -10.170000   19.875825  -0.704308  -0.301774  -0.000002  0.194439
27   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.673044   22.262090   28.306614    1.710000   37.450394  -0.284240  -0.180926  -0.000014  0.616730
28   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.666542   22.263401   28.306614    1.710000   37.450394  -0.290634  -0.179643  -0.000014  0.610350
29   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.680487   22.243486   28.306614    1.710000   37.450394  -0.276921  -0.199121  -0.000014  0.624031
30   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.663900   22.245175   28.306614    1.710000   37.450394  -0.293231  -0.197469  -0.000014  0.607759
31   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.674379   22.301309   28.306614    1.710000   37.450394  -0.282927  -0.142568  -0.000014  0.618039
32   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.660825   22.270466   28.306614    1.710000   37.450394  -0.296255  -0.172733  -0.000014  0.604742
33   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.294076    5.349940    4.665655   -5.000000   28.907344  -0.616369  -0.313776  -0.000006  0.283707
34   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.313775    5.292121    4.665655   -5.000000   28.907344  -0.596708  -0.371269  -0.000006  0.303323
35   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.121613   21.259909   27.780042    2.020000   37.024281  -0.707364  -0.342207  -0.000013  0.194934
36   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.145714   21.304889   27.780042    2.020000   37.024281  -0.683661  -0.298178  -0.000013  0.218401
37   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.126573   21.325093   27.780042    2.020000   37.024281  -0.702485  -0.278401  -0.000013  0.199764
38   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.132057   21.323211   27.780042    2.020000   37.024281  -0.697092  -0.280244  -0.000013  0.205104
39   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.708448  -12.232023  -18.023381  -10.170000   19.875825  -0.692185  -0.319447  -0.000002  0.208915
40   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.714417  -12.202504  -18.023381  -10.170000   19.875825  -0.698226  -0.289572  -0.000002  0.202934
41   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720039  -12.264469  -18.023381  -10.170000   19.875825  -0.703917  -0.352285  -0.000002  0.197300
42   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701953  -12.228550  -18.023381  -10.170000   19.875825  -0.685611  -0.315932  -0.000002  0.215423
43   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.704535  -12.213634  -18.023381  -10.170000   19.875825  -0.688224  -0.300836  -0.000002  0.212837
44   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.652920   22.230043   28.306614    1.710000   37.450394  -0.304028  -0.212269  -0.000014  0.594265
45   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.691485   22.261017   28.306614    1.710000   37.450394  -0.266106  -0.181975  -0.000014  0.631810
46   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.679119   22.305357   28.306614    1.710000   37.450394  -0.278266  -0.138609  -0.000014  0.619771
47   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.663623   22.327286   28.306614    1.710000   37.450394  -0.293503  -0.117161  -0.000014  0.604685
48   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.678524   22.282103   28.306614    1.710000   37.450394  -0.278851  -0.161352  -0.000014  0.619192
49   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.666246   22.283361   28.306614    1.710000   37.450394  -0.290925  -0.160121  -0.000014  0.607238
50   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.309929    5.340249    4.665655   -5.000000   28.907344  -0.600546  -0.323413  -0.000006  0.300148
51   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.317548    5.334102    4.665655   -5.000000   28.907344  -0.592942  -0.329524  -0.000006  0.307676
52   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.136865   21.300298   27.780042    2.020000   37.024281  -0.692364  -0.302672  -0.000013  0.204033
53   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.133538   21.291260   27.780042    2.020000   37.024281  -0.695637  -0.311519  -0.000013  0.200762
54   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.139991   21.319865   27.780042    2.020000   37.024281  -0.689290  -0.283519  -0.000013  0.207107
55   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.145748   21.330075   27.780042    2.020000   37.024281  -0.683629  -0.273524  -0.000013  0.212766
56   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702989  -12.202762  -18.023381  -10.170000   19.875825  -0.686660  -0.289833  -0.000002  0.204507
57   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.692830  -12.240287  -18.023381  -10.170000   19.875825  -0.676377  -0.327811  -0.000002  0.214786
58   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702899  -12.180291  -18.023381  -10.170000   19.875825  -0.686568  -0.267091  -0.000002  0.204598
59   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.709282  -12.282257  -18.023381  -10.170000   19.875825  -0.693029  -0.370287  -0.000002  0.198140
60   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.679330  -12.235994  -18.023381  -10.170000   19.875825  -0.662712  -0.323466  -0.000002  0.228446
61   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.695594   22.238663   28.306614    1.710000   37.450394  -0.262066  -0.203838  -0.000014  0.634200
62   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.663504   22.286354   28.306614    1.710000   37.450394  -0.293620  -0.157194  -0.000014  0.602656
63   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666457   22.254290   28.306614    1.710000   37.450394  -0.290717  -0.188555  -0.000014  0.605558
64   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666910   22.223232   28.306614    1.710000   37.450394  -0.290271  -0.218930  -0.000014  0.606004
65   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.679662   22.257256   28.306614    1.710000   37.450394  -0.277732  -0.185653  -0.000014  0.618539
66   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.676768   22.267680   28.306614    1.710000   37.450394  -0.280578  -0.175459  -0.000014  0.615693
67   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.307663    5.317330    4.665655   -5.000000   28.907344  -0.602808  -0.346202  -0.000006  0.290853
68   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.308562    5.331400    4.665655   -5.000000   28.907344  -0.601911  -0.332212  -0.000006  0.291749
–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
def table_of_samples( data47=None, data48=None, dir='output', filename=None, save_to_file=True, print_out=True, output=None):
<<<<<<< HEAD
635def table_of_samples(
636	data47 = None,
637	data48 = None,
638	dir = 'output',
639	filename = None,
640	save_to_file = True,
641	print_out = True,
642	output = None,
643	):
644	'''
645	Print out, save to disk and/or return a combined table of samples
646	for a pair of `D47data` and `D48data` objects.
647
648	**Parameters**
649
650	+ `data47`: `D47data` instance
651	+ `data48`: `D48data` instance
652	+ `dir`: the directory in which to save the table
653	+ `filename`: the name to the csv file to write to
654	+ `save_to_file`: whether to save the table to disk
655	+ `print_out`: whether to print out the table
656	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
657		if set to `'raw'`: return a list of list of strings
658		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
659	'''
660	if data47 is None:
661		if data48 is None:
662			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
663		else:
664			return data48.table_of_samples(
665				dir = dir,
666				filename = filename,
667				save_to_file = save_to_file,
668				print_out = print_out,
669				output = output
670				)
671	else:
672		if data48 is None:
673			return data47.table_of_samples(
674				dir = dir,
675				filename = filename,
676				save_to_file = save_to_file,
677				print_out = print_out,
678				output = output
679				)
680		else:
681			out47 = data47.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
682			out48 = data48.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
683			out = transpose_table(transpose_table(out47) + transpose_table(out48)[4:])
684
685			if save_to_file:
686				if not os.path.exists(dir):
687					os.makedirs(dir)
688				if filename is None:
689					filename = f'D47D48_samples.csv'
690				with open(f'{dir}/{filename}', 'w') as fid:
691					fid.write(make_csv(out))
692			if print_out:
693				print('\n'+pretty_table(out))
694			if output == 'raw':
695				return out
696			elif output == 'pretty':
697				return pretty_table(out)
=======
            
637def table_of_samples(
638	data47 = None,
639	data48 = None,
640	dir = 'output',
641	filename = None,
642	save_to_file = True,
643	print_out = True,
644	output = None,
645	):
646	'''
647	Print out, save to disk and/or return a combined table of samples
648	for a pair of `D47data` and `D48data` objects.
649
650	**Parameters**
651
652	+ `data47`: `D47data` instance
653	+ `data48`: `D48data` instance
654	+ `dir`: the directory in which to save the table
655	+ `filename`: the name to the csv file to write to
656	+ `save_to_file`: whether to save the table to disk
657	+ `print_out`: whether to print out the table
658	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
659		if set to `'raw'`: return a list of list of strings
660		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
661	'''
662	if data47 is None:
663		if data48 is None:
664			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
665		else:
666			return data48.table_of_samples(
667				dir = dir,
668				filename = filename,
669				save_to_file = save_to_file,
670				print_out = print_out,
671				output = output
672				)
673	else:
674		if data48 is None:
675			return data47.table_of_samples(
676				dir = dir,
677				filename = filename,
678				save_to_file = save_to_file,
679				print_out = print_out,
680				output = output
681				)
682		else:
683			out47 = data47.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
684			out48 = data48.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
685			out = transpose_table(transpose_table(out47) + transpose_table(out48)[4:])
686
687			if save_to_file:
688				if not os.path.exists(dir):
689					os.makedirs(dir)
690				if filename is None:
691					filename = f'D47D48_samples.csv'
692				with open(f'{dir}/{filename}', 'w') as fid:
693					fid.write(make_csv(out))
694			if print_out:
695				print('\n'+pretty_table(out))
696			if output == 'raw':
697				return out
698			elif output == 'pretty':
699				return pretty_table(out)
>>>>>>> master

Print out, save to disk and/or return a combined table of samples for a pair of D47data and D48data objects.

Parameters

  • data47: D47data instance
  • data48: D48data instance
  • dir: the directory in which to save the table
  • filename: the name to the csv file to write to
  • save_to_file: whether to save the table to disk
  • print_out: whether to print out the table
  • output: if set to 'pretty': return a pretty text table (see pretty_table()); if set to 'raw': return a list of list of strings (e.g., [['header1', 'header2'], ['0.1', '0.2']])
def table_of_sessions( data47=None, data48=None, dir='output', filename=None, save_to_file=True, print_out=True, output=None):
<<<<<<< HEAD
700def table_of_sessions(
701	data47 = None,
702	data48 = None,
703	dir = 'output',
704	filename = None,
705	save_to_file = True,
706	print_out = True,
707	output = None,
708	):
709	'''
710	Print out, save to disk and/or return a combined table of sessions
711	for a pair of `D47data` and `D48data` objects.
712	***Only applicable if the sessions in `data47` and those in `data48`
713	consist of the exact same sets of analyses.***
714
715	**Parameters**
716
717	+ `data47`: `D47data` instance
718	+ `data48`: `D48data` instance
719	+ `dir`: the directory in which to save the table
720	+ `filename`: the name to the csv file to write to
721	+ `save_to_file`: whether to save the table to disk
722	+ `print_out`: whether to print out the table
723	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
724		if set to `'raw'`: return a list of list of strings
725		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
726	'''
727	if data47 is None:
728		if data48 is None:
729			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
730		else:
731			return data48.table_of_sessions(
732				dir = dir,
733				filename = filename,
734				save_to_file = save_to_file,
735				print_out = print_out,
736				output = output
737				)
738	else:
739		if data48 is None:
740			return data47.table_of_sessions(
741				dir = dir,
742				filename = filename,
743				save_to_file = save_to_file,
744				print_out = print_out,
745				output = output
746				)
747		else:
748			out47 = data47.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
749			out48 = data48.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
750			for k,x in enumerate(out47[0]):
751				if k>7:
752					out47[0][k] = out47[0][k].replace('a', 'a_47').replace('b', 'b_47').replace('c', 'c_47')
753					out48[0][k] = out48[0][k].replace('a', 'a_48').replace('b', 'b_48').replace('c', 'c_48')
754			out = transpose_table(transpose_table(out47) + transpose_table(out48)[7:])
755
756			if save_to_file:
757				if not os.path.exists(dir):
758					os.makedirs(dir)
759				if filename is None:
760					filename = f'D47D48_sessions.csv'
761				with open(f'{dir}/{filename}', 'w') as fid:
762					fid.write(make_csv(out))
763			if print_out:
764				print('\n'+pretty_table(out))
765			if output == 'raw':
766				return out
767			elif output == 'pretty':
768				return pretty_table(out)
=======
            
702def table_of_sessions(
703	data47 = None,
704	data48 = None,
705	dir = 'output',
706	filename = None,
707	save_to_file = True,
708	print_out = True,
709	output = None,
710	):
711	'''
712	Print out, save to disk and/or return a combined table of sessions
713	for a pair of `D47data` and `D48data` objects.
714	***Only applicable if the sessions in `data47` and those in `data48`
715	consist of the exact same sets of analyses.***
716
717	**Parameters**
718
719	+ `data47`: `D47data` instance
720	+ `data48`: `D48data` instance
721	+ `dir`: the directory in which to save the table
722	+ `filename`: the name to the csv file to write to
723	+ `save_to_file`: whether to save the table to disk
724	+ `print_out`: whether to print out the table
725	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
726		if set to `'raw'`: return a list of list of strings
727		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
728	'''
729	if data47 is None:
730		if data48 is None:
731			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
732		else:
733			return data48.table_of_sessions(
734				dir = dir,
735				filename = filename,
736				save_to_file = save_to_file,
737				print_out = print_out,
738				output = output
739				)
740	else:
741		if data48 is None:
742			return data47.table_of_sessions(
743				dir = dir,
744				filename = filename,
745				save_to_file = save_to_file,
746				print_out = print_out,
747				output = output
748				)
749		else:
750			out47 = data47.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
751			out48 = data48.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
752			for k,x in enumerate(out47[0]):
753				if k>7:
754					out47[0][k] = out47[0][k].replace('a', 'a_47').replace('b', 'b_47').replace('c', 'c_47')
755					out48[0][k] = out48[0][k].replace('a', 'a_48').replace('b', 'b_48').replace('c', 'c_48')
756			out = transpose_table(transpose_table(out47) + transpose_table(out48)[7:])
757
758			if save_to_file:
759				if not os.path.exists(dir):
760					os.makedirs(dir)
761				if filename is None:
762					filename = f'D47D48_sessions.csv'
763				with open(f'{dir}/{filename}', 'w') as fid:
764					fid.write(make_csv(out))
765			if print_out:
766				print('\n'+pretty_table(out))
767			if output == 'raw':
768				return out
769			elif output == 'pretty':
770				return pretty_table(out)
>>>>>>> master

Print out, save to disk and/or return a combined table of sessions for a pair of D47data and D48data objects. Only applicable if the sessions in data47 and those in data48 consist of the exact same sets of analyses.

Parameters

  • data47: D47data instance
  • data48: D48data instance
  • dir: the directory in which to save the table
  • filename: the name to the csv file to write to
  • save_to_file: whether to save the table to disk
  • print_out: whether to print out the table
  • output: if set to 'pretty': return a pretty text table (see pretty_table()); if set to 'raw': return a list of list of strings (e.g., [['header1', 'header2'], ['0.1', '0.2']])
def table_of_analyses( data47=None, data48=None, dir='output', filename=None, save_to_file=True, print_out=True, output=None):
<<<<<<< HEAD
771def table_of_analyses(
772	data47 = None,
773	data48 = None,
774	dir = 'output',
775	filename = None,
776	save_to_file = True,
777	print_out = True,
778	output = None,
779	):
780	'''
781	Print out, save to disk and/or return a combined table of analyses
782	for a pair of `D47data` and `D48data` objects.
783
784	If the sessions in `data47` and those in `data48` do not consist of
785	the exact same sets of analyses, the table will have two columns
786	`Session_47` and `Session_48` instead of a single `Session` column.
787
788	**Parameters**
789
790	+ `data47`: `D47data` instance
791	+ `data48`: `D48data` instance
792	+ `dir`: the directory in which to save the table
793	+ `filename`: the name to the csv file to write to
794	+ `save_to_file`: whether to save the table to disk
795	+ `print_out`: whether to print out the table
796	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
797		if set to `'raw'`: return a list of list of strings
798		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
799	'''
800	if data47 is None:
801		if data48 is None:
802			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
803		else:
804			return data48.table_of_analyses(
805				dir = dir,
806				filename = filename,
807				save_to_file = save_to_file,
808				print_out = print_out,
809				output = output
810				)
811	else:
812		if data48 is None:
813			return data47.table_of_analyses(
814				dir = dir,
815				filename = filename,
816				save_to_file = save_to_file,
817				print_out = print_out,
818				output = output
819				)
820		else:
821			out47 = data47.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
822			out48 = data48.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
823			
824			if [l[1] for l in out47[1:]] == [l[1] for l in out48[1:]]: # if sessions are identical
825				out = transpose_table(transpose_table(out47) + transpose_table(out48)[-1:])
826			else:
827				out47[0][1] = 'Session_47'
828				out48[0][1] = 'Session_48'
829				out47 = transpose_table(out47)
830				out48 = transpose_table(out48)
831				out = transpose_table(out47[:2] + out48[1:2] + out47[2:] + out48[-1:])
832
833			if save_to_file:
834				if not os.path.exists(dir):
835					os.makedirs(dir)
836				if filename is None:
837					filename = f'D47D48_sessions.csv'
838				with open(f'{dir}/{filename}', 'w') as fid:
839					fid.write(make_csv(out))
840			if print_out:
841				print('\n'+pretty_table(out))
842			if output == 'raw':
843				return out
844			elif output == 'pretty':
845				return pretty_table(out)
=======
            
773def table_of_analyses(
774	data47 = None,
775	data48 = None,
776	dir = 'output',
777	filename = None,
778	save_to_file = True,
779	print_out = True,
780	output = None,
781	):
782	'''
783	Print out, save to disk and/or return a combined table of analyses
784	for a pair of `D47data` and `D48data` objects.
785
786	If the sessions in `data47` and those in `data48` do not consist of
787	the exact same sets of analyses, the table will have two columns
788	`Session_47` and `Session_48` instead of a single `Session` column.
789
790	**Parameters**
791
792	+ `data47`: `D47data` instance
793	+ `data48`: `D48data` instance
794	+ `dir`: the directory in which to save the table
795	+ `filename`: the name to the csv file to write to
796	+ `save_to_file`: whether to save the table to disk
797	+ `print_out`: whether to print out the table
798	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
799		if set to `'raw'`: return a list of list of strings
800		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
801	'''
802	if data47 is None:
803		if data48 is None:
804			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
805		else:
806			return data48.table_of_analyses(
807				dir = dir,
808				filename = filename,
809				save_to_file = save_to_file,
810				print_out = print_out,
811				output = output
812				)
813	else:
814		if data48 is None:
815			return data47.table_of_analyses(
816				dir = dir,
817				filename = filename,
818				save_to_file = save_to_file,
819				print_out = print_out,
820				output = output
821				)
822		else:
823			out47 = data47.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
824			out48 = data48.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
825			
826			if [l[1] for l in out47[1:]] == [l[1] for l in out48[1:]]: # if sessions are identical
827				out = transpose_table(transpose_table(out47) + transpose_table(out48)[-1:])
828			else:
829				out47[0][1] = 'Session_47'
830				out48[0][1] = 'Session_48'
831				out47 = transpose_table(out47)
832				out48 = transpose_table(out48)
833				out = transpose_table(out47[:2] + out48[1:2] + out47[2:] + out48[-1:])
834
835			if save_to_file:
836				if not os.path.exists(dir):
837					os.makedirs(dir)
838				if filename is None:
839					filename = f'D47D48_sessions.csv'
840				with open(f'{dir}/{filename}', 'w') as fid:
841					fid.write(make_csv(out))
842			if print_out:
843				print('\n'+pretty_table(out))
844			if output == 'raw':
845				return out
846			elif output == 'pretty':
847				return pretty_table(out)
>>>>>>> master

Print out, save to disk and/or return a combined table of analyses for a pair of D47data and D48data objects.

If the sessions in data47 and those in data48 do not consist of the exact same sets of analyses, the table will have two columns Session_47 and Session_48 instead of a single Session column.

Parameters

  • data47: D47data instance
  • data48: D48data instance
  • dir: the directory in which to save the table
  • filename: the name to the csv file to write to
  • save_to_file: whether to save the table to disk
  • print_out: whether to print out the table
  • output: if set to 'pretty': return a pretty text table (see pretty_table()); if set to 'raw': return a list of list of strings (e.g., [['header1', 'header2'], ['0.1', '0.2']])
class D4xdata(builtins.list):
<<<<<<< HEAD
 848class D4xdata(list):
 849	'''
 850	Store and process data for a large set of Δ47 and/or Δ48
 851	analyses, usually comprising more than one analytical session.
 852	'''
 853
 854	### 17O CORRECTION PARAMETERS
 855	R13_VPDB = 0.01118  # (Chang & Li, 1990)
 856	'''
 857	Absolute (13C/12C) ratio of VPDB.
 858	By default equal to 0.01118 ([Chang & Li, 1990](http://www.cnki.com.cn/Article/CJFDTotal-JXTW199004006.htm))
 859	'''
 860
 861	R18_VSMOW = 0.0020052  # (Baertschi, 1976)
 862	'''
 863	Absolute (18O/16C) ratio of VSMOW.
 864	By default equal to 0.0020052 ([Baertschi, 1976](https://doi.org/10.1016/0012-821X(76)90115-1))
 865	'''
 866
 867	LAMBDA_17 = 0.528  # (Barkan & Luz, 2005)
 868	'''
 869	Mass-dependent exponent for triple oxygen isotopes.
 870	By default equal to 0.528 ([Barkan & Luz, 2005](https://doi.org/10.1002/rcm.2250))
 871	'''
 872
 873	R17_VSMOW = 0.00038475  # (Assonov & Brenninkmeijer, 2003, rescaled to R13_VPDB)
 874	'''
 875	Absolute (17O/16C) ratio of VSMOW.
 876	By default equal to 0.00038475
 877	([Assonov & Brenninkmeijer, 2003](https://dx.doi.org/10.1002/rcm.1011),
 878	rescaled to `R13_VPDB`)
 879	'''
 880
 881	R18_VPDB = R18_VSMOW * 1.03092
 882	'''
 883	Absolute (18O/16C) ratio of VPDB.
 884	By definition equal to `R18_VSMOW * 1.03092`.
 885	'''
 886
 887	R17_VPDB = R17_VSMOW * 1.03092 ** LAMBDA_17
 888	'''
 889	Absolute (17O/16C) ratio of VPDB.
 890	By definition equal to `R17_VSMOW * 1.03092 ** LAMBDA_17`.
 891	'''
 892
 893	LEVENE_REF_SAMPLE = 'ETH-3'
 894	'''
 895	After the Δ4x standardization step, each sample is tested to
 896	assess whether the Δ4x variance within all analyses for that
 897	sample differs significantly from that observed for a given reference
 898	sample (using [Levene's test](https://en.wikipedia.org/wiki/Levene%27s_test),
 899	which yields a p-value corresponding to the null hypothesis that the
 900	underlying variances are equal).
 901
 902	`LEVENE_REF_SAMPLE` (by default equal to `'ETH-3'`) specifies which
 903	sample should be used as a reference for this test.
 904	'''
 905
 906	ALPHA_18O_ACID_REACTION = round(np.exp(3.59 / (90 + 273.15) - 1.79e-3), 6)  # (Kim et al., 2007, calcite)
 907	'''
 908	Specifies the 18O/16O fractionation factor generally applicable
 909	to acid reactions in the dataset. Currently used by `D4xdata.wg()`,
 910	`D4xdata.standardize_d13C`, and `D4xdata.standardize_d18O`.
 911
 912	By default equal to 1.008129 (calcite reacted at 90 °C,
 913	[Kim et al., 2007](https://dx.doi.org/10.1016/j.chemgeo.2007.08.005)).
 914	'''
 915
 916	Nominal_d13C_VPDB = {
 917		'ETH-1': 2.02,
 918		'ETH-2': -10.17,
 919		'ETH-3': 1.71,
 920		}	# (Bernasconi et al., 2018)
 921	'''
 922	Nominal δ13C_VPDB values assigned to carbonate standards, used by
 923	`D4xdata.standardize_d13C()`.
 924
 925	By default equal to `{'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71}` after
 926	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
 927	'''
 928
 929	Nominal_d18O_VPDB = {
 930		'ETH-1': -2.19,
 931		'ETH-2': -18.69,
 932		'ETH-3': -1.78,
 933		}	# (Bernasconi et al., 2018)
 934	'''
 935	Nominal δ18O_VPDB values assigned to carbonate standards, used by
 936	`D4xdata.standardize_d18O()`.
 937
 938	By default equal to `{'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78}` after
 939	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
 940	'''
 941
 942	d13C_STANDARDIZATION_METHOD = '2pt'
 943	'''
 944	Method by which to standardize δ13C values:
 945	
 946	+ `none`: do not apply any δ13C standardization.
 947	+ `'1pt'`: within each session, offset all initial δ13C values so as to
 948	minimize the difference between final δ13C_VPDB values and
 949	`Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB` is defined).
 950	+ `'2pt'`: within each session, apply a affine trasformation to all δ13C
 951	values so as to minimize the difference between final δ13C_VPDB
 952	values and `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB`
 953	is defined).
 954	'''
 955
 956	d18O_STANDARDIZATION_METHOD = '2pt'
 957	'''
 958	Method by which to standardize δ18O values:
 959	
 960	+ `none`: do not apply any δ18O standardization.
 961	+ `'1pt'`: within each session, offset all initial δ18O values so as to
 962	minimize the difference between final δ18O_VPDB values and
 963	`Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` is defined).
 964	+ `'2pt'`: within each session, apply a affine trasformation to all δ18O
 965	values so as to minimize the difference between final δ18O_VPDB
 966	values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB`
 967	is defined).
 968	'''
 969
 970	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
 971		'''
 972		**Parameters**
 973
 974		+ `l`: a list of dictionaries, with each dictionary including at least the keys
 975		`Sample`, `d45`, `d46`, and `d47` or `d48`.
 976		+ `mass`: `'47'` or `'48'`
 977		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
 978		+ `session`: define session name for analyses without a `Session` key
 979		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.
 980
 981		Returns a `D4xdata` object derived from `list`.
 982		'''
 983		self._4x = mass
 984		self.verbose = verbose
 985		self.prefix = 'D4xdata'
 986		self.logfile = logfile
 987		list.__init__(self, l)
 988		self.Nf = None
 989		self.repeatability = {}
 990		self.refresh(session = session)
 991
 992
 993	def make_verbal(oldfun):
 994		'''
 995		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
 996		'''
 997		@wraps(oldfun)
 998		def newfun(*args, verbose = '', **kwargs):
 999			myself = args[0]
1000			oldprefix = myself.prefix
1001			myself.prefix = oldfun.__name__
1002			if verbose != '':
1003				oldverbose = myself.verbose
1004				myself.verbose = verbose
1005			out = oldfun(*args, **kwargs)
1006			myself.prefix = oldprefix
1007			if verbose != '':
1008				myself.verbose = oldverbose
1009			return out
1010		return newfun
1011
1012
1013	def msg(self, txt):
1014		'''
1015		Log a message to `self.logfile`, and print it out if `verbose = True`
1016		'''
1017		self.log(txt)
1018		if self.verbose:
1019			print(f'{f"[{self.prefix}]":<16} {txt}')
1020
1021
1022	def vmsg(self, txt):
1023		'''
1024		Log a message to `self.logfile` and print it out
1025		'''
1026		self.log(txt)
1027		print(txt)
1028
1029
1030	def log(self, *txts):
1031		'''
1032		Log a message to `self.logfile`
1033		'''
1034		if self.logfile:
1035			with open(self.logfile, 'a') as fid:
1036				for txt in txts:
1037					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
1038
1039
1040	def refresh(self, session = 'mySession'):
1041		'''
1042		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
1043		'''
1044		self.fill_in_missing_info(session = session)
1045		self.refresh_sessions()
1046		self.refresh_samples()
1047
1048
1049	def refresh_sessions(self):
1050		'''
1051		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
1052		to `False` for all sessions.
1053		'''
1054		self.sessions = {
1055			s: {'data': [r for r in self if r['Session'] == s]}
1056			for s in sorted({r['Session'] for r in self})
1057			}
1058		for s in self.sessions:
1059			self.sessions[s]['scrambling_drift'] = False
1060			self.sessions[s]['slope_drift'] = False
1061			self.sessions[s]['wg_drift'] = False
1062			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
1063			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
1064
1065
1066	def refresh_samples(self):
1067		'''
1068		Define `self.samples`, `self.anchors`, and `self.unknowns`.
1069		'''
1070		self.samples = {
1071			s: {'data': [r for r in self if r['Sample'] == s]}
1072			for s in sorted({r['Sample'] for r in self})
1073			}
1074		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
1075		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
1076
1077
1078	def read(self, filename, sep = '', session = ''):
1079		'''
1080		Read file in csv format to load data into a `D47data` object.
1081
1082		In the csv file, spaces before and after field separators (`','` by default)
1083		are optional. Each line corresponds to a single analysis.
1084
1085		The required fields are:
1086
1087		+ `UID`: a unique identifier
1088		+ `Session`: an identifier for the analytical session
1089		+ `Sample`: a sample identifier
1090		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
1091
1092		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
1093		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
1094		and `d49` are optional, and set to NaN by default.
1095
1096		**Parameters**
1097
1098		+ `fileneme`: the path of the file to read
1099		+ `sep`: csv separator delimiting the fields
1100		+ `session`: set `Session` field to this string for all analyses
1101		'''
1102		with open(filename) as fid:
1103			self.input(fid.read(), sep = sep, session = session)
1104
1105
1106	def input(self, txt, sep = '', session = ''):
1107		'''
1108		Read `txt` string in csv format to load analysis data into a `D47data` object.
1109
1110		In the csv string, spaces before and after field separators (`','` by default)
1111		are optional. Each line corresponds to a single analysis.
1112
1113		The required fields are:
1114
1115		+ `UID`: a unique identifier
1116		+ `Session`: an identifier for the analytical session
1117		+ `Sample`: a sample identifier
1118		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
1119
1120		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
1121		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
1122		and `d49` are optional, and set to NaN by default.
1123
1124		**Parameters**
1125
1126		+ `txt`: the csv string to read
1127		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
1128		whichever appers most often in `txt`.
1129		+ `session`: set `Session` field to this string for all analyses
1130		'''
1131		if sep == '':
1132			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
1133		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
1134		data = [{k: v if k in ['UID', 'Session', 'Sample'] else smart_type(v) for k,v in zip(txt[0], l) if v != ''} for l in txt[1:]]
1135
1136		if session != '':
1137			for r in data:
1138				r['Session'] = session
1139
1140		self += data
1141		self.refresh()
1142
1143
1144	@make_verbal
1145	def wg(self, samples = None, a18_acid = None):
1146		'''
1147		Compute bulk composition of the working gas for each session based on
1148		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
1149		`self.Nominal_d18O_VPDB`.
1150		'''
1151
1152		self.msg('Computing WG composition:')
1153
1154		if a18_acid is None:
1155			a18_acid = self.ALPHA_18O_ACID_REACTION
1156		if samples is None:
1157			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
1158
1159		assert a18_acid, f'Acid fractionation factor should not be zero.'
1160
1161		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
1162		R45R46_standards = {}
1163		for sample in samples:
1164			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
1165			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
1166			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
1167			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
1168			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
1169
1170			C12_s = 1 / (1 + R13_s)
1171			C13_s = R13_s / (1 + R13_s)
1172			C16_s = 1 / (1 + R17_s + R18_s)
1173			C17_s = R17_s / (1 + R17_s + R18_s)
1174			C18_s = R18_s / (1 + R17_s + R18_s)
1175
1176			C626_s = C12_s * C16_s ** 2
1177			C627_s = 2 * C12_s * C16_s * C17_s
1178			C628_s = 2 * C12_s * C16_s * C18_s
1179			C636_s = C13_s * C16_s ** 2
1180			C637_s = 2 * C13_s * C16_s * C17_s
1181			C727_s = C12_s * C17_s ** 2
1182
1183			R45_s = (C627_s + C636_s) / C626_s
1184			R46_s = (C628_s + C637_s + C727_s) / C626_s
1185			R45R46_standards[sample] = (R45_s, R46_s)
1186		
1187		for s in self.sessions:
1188			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
1189			assert db, f'No sample from {samples} found in session "{s}".'
1190# 			dbsamples = sorted({r['Sample'] for r in db})
1191
1192			X = [r['d45'] for r in db]
1193			Y = [R45R46_standards[r['Sample']][0] for r in db]
1194			x1, x2 = np.min(X), np.max(X)
1195
1196			if x1 < x2:
1197				wgcoord = x1/(x1-x2)
1198			else:
1199				wgcoord = 999
1200
1201			if wgcoord < -.5 or wgcoord > 1.5:
1202				# unreasonable to extrapolate to d45 = 0
1203				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
1204			else :
1205				# d45 = 0 is reasonably well bracketed
1206				R45_wg = np.polyfit(X, Y, 1)[1]
1207
1208			X = [r['d46'] for r in db]
1209			Y = [R45R46_standards[r['Sample']][1] for r in db]
1210			x1, x2 = np.min(X), np.max(X)
1211
1212			if x1 < x2:
1213				wgcoord = x1/(x1-x2)
1214			else:
1215				wgcoord = 999
1216
1217			if wgcoord < -.5 or wgcoord > 1.5:
1218				# unreasonable to extrapolate to d46 = 0
1219				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
1220			else :
1221				# d46 = 0 is reasonably well bracketed
1222				R46_wg = np.polyfit(X, Y, 1)[1]
1223
1224			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
1225
1226			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
1227
1228			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
1229			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
1230			for r in self.sessions[s]['data']:
1231				r['d13Cwg_VPDB'] = d13Cwg_VPDB
1232				r['d18Owg_VSMOW'] = d18Owg_VSMOW
1233
1234
1235	def compute_bulk_delta(self, R45, R46, D17O = 0):
1236		'''
1237		Compute δ13C_VPDB and δ18O_VSMOW,
1238		by solving the generalized form of equation (17) from
1239		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
1240		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
1241		solving the corresponding second-order Taylor polynomial.
1242		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
1243		'''
1244
1245		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
1246
1247		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
1248		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
1249		C = 2 * self.R18_VSMOW
1250		D = -R46
1251
1252		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
1253		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
1254		cc = A + B + C + D
1255
1256		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
1257
1258		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
1259		R17 = K * R18 ** self.LAMBDA_17
1260		R13 = R45 - 2 * R17
1261
1262		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
1263
1264		return d13C_VPDB, d18O_VSMOW
1265
1266
1267	@make_verbal
1268	def crunch(self, verbose = ''):
1269		'''
1270		Compute bulk composition and raw clumped isotope anomalies for all analyses.
1271		'''
1272		for r in self:
1273			self.compute_bulk_and_clumping_deltas(r)
1274		self.standardize_d13C()
1275		self.standardize_d18O()
1276		self.msg(f"Crunched {len(self)} analyses.")
1277
1278
1279	def fill_in_missing_info(self, session = 'mySession'):
1280		'''
1281		Fill in optional fields with default values
1282		'''
1283		for i,r in enumerate(self):
1284			if 'D17O' not in r:
1285				r['D17O'] = 0.
1286			if 'UID' not in r:
1287				r['UID'] = f'{i+1}'
1288			if 'Session' not in r:
1289				r['Session'] = session
1290			for k in ['d47', 'd48', 'd49']:
1291				if k not in r:
1292					r[k] = np.nan
1293
1294
1295	def standardize_d13C(self):
1296		'''
1297		Perform δ13C standadization within each session `s` according to
1298		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
1299		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
1300		may be redefined abitrarily at a later stage.
1301		'''
1302		for s in self.sessions:
1303			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
1304				XY = [(r['d13C_VPDB'], self.Nominal_d13C_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d13C_VPDB]
1305				X,Y = zip(*XY)
1306				if self.sessions[s]['d13C_standardization_method'] == '1pt':
1307					offset = np.mean(Y) - np.mean(X)
1308					for r in self.sessions[s]['data']:
1309						r['d13C_VPDB'] += offset				
1310				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
1311					a,b = np.polyfit(X,Y,1)
1312					for r in self.sessions[s]['data']:
1313						r['d13C_VPDB'] = a * r['d13C_VPDB'] + b
1314
1315	def standardize_d18O(self):
1316		'''
1317		Perform δ18O standadization within each session `s` according to
1318		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
1319		which is defined by default by `D47data.refresh_sessions()`as equal to
1320		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
1321		'''
1322		for s in self.sessions:
1323			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
1324				XY = [(r['d18O_VSMOW'], self.Nominal_d18O_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d18O_VPDB]
1325				X,Y = zip(*XY)
1326				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
1327				if self.sessions[s]['d18O_standardization_method'] == '1pt':
1328					offset = np.mean(Y) - np.mean(X)
1329					for r in self.sessions[s]['data']:
1330						r['d18O_VSMOW'] += offset				
1331				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
1332					a,b = np.polyfit(X,Y,1)
1333					for r in self.sessions[s]['data']:
1334						r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b
1335	
1336
1337	def compute_bulk_and_clumping_deltas(self, r):
1338		'''
1339		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
1340		'''
1341
1342		# Compute working gas R13, R18, and isobar ratios
1343		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
1344		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
1345		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
1346
1347		# Compute analyte isobar ratios
1348		R45 = (1 + r['d45'] / 1000) * R45_wg
1349		R46 = (1 + r['d46'] / 1000) * R46_wg
1350		R47 = (1 + r['d47'] / 1000) * R47_wg
1351		R48 = (1 + r['d48'] / 1000) * R48_wg
1352		R49 = (1 + r['d49'] / 1000) * R49_wg
1353
1354		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
1355		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
1356		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
1357
1358		# Compute stochastic isobar ratios of the analyte
1359		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
1360			R13, R18, D17O = r['D17O']
1361		)
1362
1363		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
1364		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
1365		if (R45 / R45stoch - 1) > 5e-8:
1366			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
1367		if (R46 / R46stoch - 1) > 5e-8:
1368			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
1369
1370		# Compute raw clumped isotope anomalies
1371		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
1372		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
1373		r['D49raw'] = 1000 * (R49 / R49stoch - 1)
1374
1375
1376	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
1377		'''
1378		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
1379		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
1380		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
1381		'''
1382
1383		# Compute R17
1384		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
1385
1386		# Compute isotope concentrations
1387		C12 = (1 + R13) ** -1
1388		C13 = C12 * R13
1389		C16 = (1 + R17 + R18) ** -1
1390		C17 = C16 * R17
1391		C18 = C16 * R18
1392
1393		# Compute stochastic isotopologue concentrations
1394		C626 = C16 * C12 * C16
1395		C627 = C16 * C12 * C17 * 2
1396		C628 = C16 * C12 * C18 * 2
1397		C636 = C16 * C13 * C16
1398		C637 = C16 * C13 * C17 * 2
1399		C638 = C16 * C13 * C18 * 2
1400		C727 = C17 * C12 * C17
1401		C728 = C17 * C12 * C18 * 2
1402		C737 = C17 * C13 * C17
1403		C738 = C17 * C13 * C18 * 2
1404		C828 = C18 * C12 * C18
1405		C838 = C18 * C13 * C18
1406
1407		# Compute stochastic isobar ratios
1408		R45 = (C636 + C627) / C626
1409		R46 = (C628 + C637 + C727) / C626
1410		R47 = (C638 + C728 + C737) / C626
1411		R48 = (C738 + C828) / C626
1412		R49 = C838 / C626
1413
1414		# Account for stochastic anomalies
1415		R47 *= 1 + D47 / 1000
1416		R48 *= 1 + D48 / 1000
1417		R49 *= 1 + D49 / 1000
1418
1419		# Return isobar ratios
1420		return R45, R46, R47, R48, R49
1421
1422
1423	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
1424		'''
1425		Split unknown samples by UID (treat all analyses as different samples)
1426		or by session (treat analyses of a given sample in different sessions as
1427		different samples).
1428
1429		**Parameters**
1430
1431		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
1432		+ `grouping`: `by_uid` | `by_session`
1433		'''
1434		if samples_to_split == 'all':
1435			samples_to_split = [s for s in self.unknowns]
1436		gkeys = {'by_uid':'UID', 'by_session':'Session'}
1437		self.grouping = grouping.lower()
1438		if self.grouping in gkeys:
1439			gkey = gkeys[self.grouping]
1440		for r in self:
1441			if r['Sample'] in samples_to_split:
1442				r['Sample_original'] = r['Sample']
1443				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
1444			elif r['Sample'] in self.unknowns:
1445				r['Sample_original'] = r['Sample']
1446		self.refresh_samples()
1447
1448
1449	def unsplit_samples(self, tables = False):
1450		'''
1451		Reverse the effects of `D47data.split_samples()`.
1452		
1453		This should only be used after `D4xdata.standardize()` with `method='pooled'`.
1454		
1455		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
1456		probably use `D4xdata.combine_samples()` instead to reverse the effects of
1457		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
1458		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
1459		that case session-averaged Δ4x values are statistically independent).
1460		'''
1461		unknowns_old = sorted({s for s in self.unknowns})
1462		CM_old = self.standardization.covar[:,:]
1463		VD_old = self.standardization.params.valuesdict().copy()
1464		vars_old = self.standardization.var_names
1465
1466		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
1467
1468		Ns = len(vars_old) - len(unknowns_old)
1469		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
1470		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
1471
1472		W = np.zeros((len(vars_new), len(vars_old)))
1473		W[:Ns,:Ns] = np.eye(Ns)
1474		for u in unknowns_new:
1475			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
1476			if self.grouping == 'by_session':
1477				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
1478			elif self.grouping == 'by_uid':
1479				weights = [1 for s in splits]
1480			sw = sum(weights)
1481			weights = [w/sw for w in weights]
1482			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
1483
1484		CM_new = W @ CM_old @ W.T
1485		V = W @ np.array([[VD_old[k]] for k in vars_old])
1486		VD_new = {k:v[0] for k,v in zip(vars_new, V)}
1487
1488		self.standardization.covar = CM_new
1489		self.standardization.params.valuesdict = lambda : VD_new
1490		self.standardization.var_names = vars_new
1491
1492		for r in self:
1493			if r['Sample'] in self.unknowns:
1494				r['Sample_split'] = r['Sample']
1495				r['Sample'] = r['Sample_original']
1496
1497		self.refresh_samples()
1498		self.consolidate_samples()
1499		self.repeatabilities()
1500
1501		if tables:
1502			self.table_of_analyses()
1503			self.table_of_samples()
1504
1505	def assign_timestamps(self):
1506		'''
1507		Assign a time field `t` of type `float` to each analysis.
1508
1509		If `TimeTag` is one of the data fields, `t` is equal within a given session
1510		to `TimeTag` minus the mean value of `TimeTag` for that session.
1511		Otherwise, `TimeTag` is by default equal to the index of each analysis
1512		in the dataset and `t` is defined as above.
1513		'''
1514		for session in self.sessions:
1515			sdata = self.sessions[session]['data']
1516			try:
1517				t0 = np.mean([r['TimeTag'] for r in sdata])
1518				for r in sdata:
1519					r['t'] = r['TimeTag'] - t0
1520			except KeyError:
1521				t0 = (len(sdata)-1)/2
1522				for t,r in enumerate(sdata):
1523					r['t'] = t - t0
1524
1525
1526	def report(self):
1527		'''
1528		Prints a report on the standardization fit.
1529		Only applicable after `D4xdata.standardize(method='pooled')`.
1530		'''
1531		report_fit(self.standardization)
1532
1533
1534	def combine_samples(self, sample_groups):
1535		'''
1536		Combine analyses of different samples to compute weighted average Δ4x
1537		and new error (co)variances corresponding to the groups defined by the `sample_groups`
1538		dictionary.
1539		
1540		Caution: samples are weighted by number of replicate analyses, which is a
1541		reasonable default behavior but is not always optimal (e.g., in the case of strongly
1542		correlated analytical errors for one or more samples).
1543		
1544		Returns a tuplet of:
1545		
1546		+ the list of group names
1547		+ an array of the corresponding Δ4x values
1548		+ the corresponding (co)variance matrix
1549		
1550		**Parameters**
1551
1552		+ `sample_groups`: a dictionary of the form:
1553		```py
1554		{'group1': ['sample_1', 'sample_2'],
1555		 'group2': ['sample_3', 'sample_4', 'sample_5']}
1556		```
1557		'''
1558		
1559		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
1560		groups = sorted(sample_groups.keys())
1561		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
1562		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
1563		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
1564		W = np.array([
1565			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
1566			for j in groups])
1567		D4x_new = W @ D4x_old
1568		CM_new = W @ CM_old @ W.T
1569
1570		return groups, D4x_new[:,0], CM_new
1571		
1572
1573	@make_verbal
1574	def standardize(self,
1575		method = 'pooled',
1576		weighted_sessions = [],
1577		consolidate = True,
1578		consolidate_tables = False,
1579		consolidate_plots = False,
1580		constraints = {},
1581		):
1582		'''
1583		Compute absolute Δ4x values for all replicate analyses and for sample averages.
1584		If `method` argument is set to `'pooled'`, the standardization processes all sessions
1585		in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
1586		i.e. that their true Δ4x value does not change between sessions,
1587		([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
1588		`'indep_sessions'`, the standardization processes each session independently, based only
1589		on anchors analyses.
1590		'''
1591
1592		self.standardization_method = method
1593		self.assign_timestamps()
1594
1595		if method == 'pooled':
1596			if weighted_sessions:
1597				for session_group in weighted_sessions:
1598					if self._4x == '47':
1599						X = D47data([r for r in self if r['Session'] in session_group])
1600					elif self._4x == '48':
1601						X = D48data([r for r in self if r['Session'] in session_group])
1602					X.Nominal_D4x = self.Nominal_D4x.copy()
1603					X.refresh()
1604					result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
1605					w = np.sqrt(result.redchi)
1606					self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
1607					for r in X:
1608						r[f'wD{self._4x}raw'] *= w
1609			else:
1610				self.msg(f'All D{self._4x}raw weights set to 1 ‰')
1611				for r in self:
1612					r[f'wD{self._4x}raw'] = 1.
1613
1614			params = Parameters()
1615			for k,session in enumerate(self.sessions):
1616				self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
1617				self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
1618				self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
1619				s = pf(session)
1620				params.add(f'a_{s}', value = 0.9)
1621				params.add(f'b_{s}', value = 0.)
1622				params.add(f'c_{s}', value = -0.9)
1623				params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift'])
1624				params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift'])
1625				params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift'])
1626			for sample in self.unknowns:
1627				params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)
1628
1629			for k in constraints:
1630				params[k].expr = constraints[k]
1631
1632			def residuals(p):
1633				R = []
1634				for r in self:
1635					session = pf(r['Session'])
1636					sample = pf(r['Sample'])
1637					if r['Sample'] in self.Nominal_D4x:
1638						R += [ (
1639							r[f'D{self._4x}raw'] - (
1640								p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
1641								+ p[f'b_{session}'] * r[f'd{self._4x}']
1642								+	p[f'c_{session}']
1643								+ r['t'] * (
1644									p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
1645									+ p[f'b2_{session}'] * r[f'd{self._4x}']
1646									+	p[f'c2_{session}']
1647									)
1648								)
1649							) / r[f'wD{self._4x}raw'] ]
1650					else:
1651						R += [ (
1652							r[f'D{self._4x}raw'] - (
1653								p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
1654								+ p[f'b_{session}'] * r[f'd{self._4x}']
1655								+	p[f'c_{session}']
1656								+ r['t'] * (
1657									p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
1658									+ p[f'b2_{session}'] * r[f'd{self._4x}']
1659									+	p[f'c2_{session}']
1660									)
1661								)
1662							) / r[f'wD{self._4x}raw'] ]
1663				return R
1664
1665			M = Minimizer(residuals, params)
1666			result = M.least_squares()
1667			self.Nf = result.nfree
1668			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
1669# 			if self.verbose:
1670# 				report_fit(result)
1671
1672			for r in self:
1673				s = pf(r["Session"])
1674				a = result.params.valuesdict()[f'a_{s}']
1675				b = result.params.valuesdict()[f'b_{s}']
1676				c = result.params.valuesdict()[f'c_{s}']
1677				a2 = result.params.valuesdict()[f'a2_{s}']
1678				b2 = result.params.valuesdict()[f'b2_{s}']
1679				c2 = result.params.valuesdict()[f'c2_{s}']
1680				r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
1681
1682			self.standardization = result
1683
1684			for session in self.sessions:
1685				self.sessions[session]['Np'] = 3
1686				for k in ['scrambling', 'slope', 'wg']:
1687					if self.sessions[session][f'{k}_drift']:
1688						self.sessions[session]['Np'] += 1
1689
1690			if consolidate:
1691				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
1692			return result
1693
1694
1695		elif method == 'indep_sessions':
1696
1697			if weighted_sessions:
1698				for session_group in weighted_sessions:
1699					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
1700					X.Nominal_D4x = self.Nominal_D4x.copy()
1701					X.refresh()
1702					# This is only done to assign r['wD47raw'] for r in X:
1703					X.standardize(method = method, weighted_sessions = [], consolidate = False)
1704					self.msg(f'D{self._4x}raw weights set to {1000*X[0][f"wD{self._4x}raw"]:.1f} ppm for sessions in {session_group}')
1705			else:
1706				self.msg('All weights set to 1 ‰')
1707				for r in self:
1708					r[f'wD{self._4x}raw'] = 1
1709
1710			for session in self.sessions:
1711				s = self.sessions[session]
1712				p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
1713				p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
1714				s['Np'] = sum(p_active)
1715				sdata = s['data']
1716
1717				A = np.array([
1718					[
1719						self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
1720						r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
1721						1 / r[f'wD{self._4x}raw'],
1722						self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
1723						r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
1724						r['t'] / r[f'wD{self._4x}raw']
1725						]
1726					for r in sdata if r['Sample'] in self.anchors
1727					])[:,p_active] # only keep columns for the active parameters
1728				Y = np.array([[r[f'D{self._4x}raw'] / r[f'wD{self._4x}raw']] for r in sdata if r['Sample'] in self.anchors])
1729				s['Na'] = Y.size
1730				CM = linalg.inv(A.T @ A)
1731				bf = (CM @ A.T @ Y).T[0,:]
1732				k = 0
1733				for n,a in zip(p_names, p_active):
1734					if a:
1735						s[n] = bf[k]
1736# 						self.msg(f'{n} = {bf[k]}')
1737						k += 1
1738					else:
1739						s[n] = 0.
1740# 						self.msg(f'{n} = 0.0')
1741
1742				for r in sdata :
1743					a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
1744					r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
1745					r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])
1746
1747				s['CM'] = np.zeros((6,6))
1748				i = 0
1749				k_active = [j for j,a in enumerate(p_active) if a]
1750				for j,a in enumerate(p_active):
1751					if a:
1752						s['CM'][j,k_active] = CM[i,:]
1753						i += 1
1754
1755			if not weighted_sessions:
1756				w = self.rmswd()['rmswd']
1757				for r in self:
1758						r[f'wD{self._4x}'] *= w
1759						r[f'wD{self._4x}raw'] *= w
1760				for session in self.sessions:
1761					self.sessions[session]['CM'] *= w**2
1762
1763			for session in self.sessions:
1764				s = self.sessions[session]
1765				s['SE_a'] = s['CM'][0,0]**.5
1766				s['SE_b'] = s['CM'][1,1]**.5
1767				s['SE_c'] = s['CM'][2,2]**.5
1768				s['SE_a2'] = s['CM'][3,3]**.5
1769				s['SE_b2'] = s['CM'][4,4]**.5
1770				s['SE_c2'] = s['CM'][5,5]**.5
1771
1772			if not weighted_sessions:
1773				self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
1774			else:
1775				self.Nf = 0
1776				for sg in weighted_sessions:
1777					self.Nf += self.rmswd(sessions = sg)['Nf']
1778
1779			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
1780
1781			avgD4x = {
1782				sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
1783				for sample in self.samples
1784				}
1785			chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
1786			rD4x = (chi2/self.Nf)**.5
1787			self.repeatability[f'sigma_{self._4x}'] = rD4x
1788
1789			if consolidate:
1790				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
1791
1792
1793	def standardization_error(self, session, d4x, D4x, t = 0):
1794		'''
1795		Compute standardization error for a given session and
1796		(δ47, Δ47) composition.
1797		'''
1798		a = self.sessions[session]['a']
1799		b = self.sessions[session]['b']
1800		c = self.sessions[session]['c']
1801		a2 = self.sessions[session]['a2']
1802		b2 = self.sessions[session]['b2']
1803		c2 = self.sessions[session]['c2']
1804		CM = self.sessions[session]['CM']
1805
1806		x, y = D4x, d4x
1807		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
1808# 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
1809		dxdy = -(b+b2*t) / (a+a2*t)
1810		dxdz = 1. / (a+a2*t)
1811		dxda = -x / (a+a2*t)
1812		dxdb = -y / (a+a2*t)
1813		dxdc = -1. / (a+a2*t)
1814		dxda2 = -x * a2 / (a+a2*t)
1815		dxdb2 = -y * t / (a+a2*t)
1816		dxdc2 = -t / (a+a2*t)
1817		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
1818		sx = (V @ CM @ V.T) ** .5
1819		return sx
1820
1821
1822	@make_verbal
1823	def summary(self,
1824		dir = 'output',
1825		filename = None,
1826		save_to_file = True,
1827		print_out = True,
1828		):
1829		'''
1830		Print out an/or save to disk a summary of the standardization results.
1831
1832		**Parameters**
1833
1834		+ `dir`: the directory in which to save the table
1835		+ `filename`: the name to the csv file to write to
1836		+ `save_to_file`: whether to save the table to disk
1837		+ `print_out`: whether to print out the table
1838		'''
1839
1840		out = []
1841		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
1842		out += [['N analyses (anchors + unknowns)', f"{len(self)} ({len([r for r in self if r['Sample'] in self.anchors])} + {len([r for r in self if r['Sample'] in self.unknowns])})"]]
1843		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
1844		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
1845		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
1846		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
1847		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
1848		out += [['Model degrees of freedom', f"{self.Nf}"]]
1849		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
1850		out += [['Standardization method', self.standardization_method]]
1851
1852		if save_to_file:
1853			if not os.path.exists(dir):
1854				os.makedirs(dir)
1855			if filename is None:
1856				filename = f'D{self._4x}_summary.csv'
1857			with open(f'{dir}/{filename}', 'w') as fid:
1858				fid.write(make_csv(out))
1859		if print_out:
1860			self.msg('\n' + pretty_table(out, header = 0))
1861
1862
1863	@make_verbal
1864	def table_of_sessions(self,
1865		dir = 'output',
1866		filename = None,
1867		save_to_file = True,
1868		print_out = True,
1869		output = None,
1870		):
1871		'''
1872		Print out an/or save to disk a table of sessions.
1873
1874		**Parameters**
1875
1876		+ `dir`: the directory in which to save the table
1877		+ `filename`: the name to the csv file to write to
1878		+ `save_to_file`: whether to save the table to disk
1879		+ `print_out`: whether to print out the table
1880		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
1881		    if set to `'raw'`: return a list of list of strings
1882		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
1883		'''
1884		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
1885		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
1886		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
1887
1888		out = [['Session','Na','Nu','d13Cwg_VPDB','d18Owg_VSMOW','r_d13C','r_d18O',f'r_D{self._4x}','a ± SE','1e3 x b ± SE','c ± SE']]
1889		if include_a2:
1890			out[-1] += ['a2 ± SE']
1891		if include_b2:
1892			out[-1] += ['b2 ± SE']
1893		if include_c2:
1894			out[-1] += ['c2 ± SE']
1895		for session in self.sessions:
1896			out += [[
1897				session,
1898				f"{self.sessions[session]['Na']}",
1899				f"{self.sessions[session]['Nu']}",
1900				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
1901				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
1902				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
1903				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
1904				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
1905				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
1906				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
1907				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
1908				]]
1909			if include_a2:
1910				if self.sessions[session]['scrambling_drift']:
1911					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
1912				else:
1913					out[-1] += ['']
1914			if include_b2:
1915				if self.sessions[session]['slope_drift']:
1916					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
1917				else:
1918					out[-1] += ['']
1919			if include_c2:
1920				if self.sessions[session]['wg_drift']:
1921					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
1922				else:
1923					out[-1] += ['']
1924
1925		if save_to_file:
1926			if not os.path.exists(dir):
1927				os.makedirs(dir)
1928			if filename is None:
1929				filename = f'D{self._4x}_sessions.csv'
1930			with open(f'{dir}/{filename}', 'w') as fid:
1931				fid.write(make_csv(out))
1932		if print_out:
1933			self.msg('\n' + pretty_table(out))
1934		if output == 'raw':
1935			return out
1936		elif output == 'pretty':
1937			return pretty_table(out)
1938
1939
1940	@make_verbal
1941	def table_of_analyses(
1942		self,
1943		dir = 'output',
1944		filename = None,
1945		save_to_file = True,
1946		print_out = True,
1947		output = None,
1948		):
1949		'''
1950		Print out an/or save to disk a table of analyses.
1951
1952		**Parameters**
1953
1954		+ `dir`: the directory in which to save the table
1955		+ `filename`: the name to the csv file to write to
1956		+ `save_to_file`: whether to save the table to disk
1957		+ `print_out`: whether to print out the table
1958		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
1959		    if set to `'raw'`: return a list of list of strings
1960		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
1961		'''
1962
1963		out = [['UID','Session','Sample']]
1964		extra_fields = [f for f in [('SampleMass','.2f'),('ColdFingerPressure','.1f'),('AcidReactionYield','.3f')] if f[0] in {k for r in self for k in r}]
1965		for f in extra_fields:
1966			out[-1] += [f[0]]
1967		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
1968		for r in self:
1969			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
1970			for f in extra_fields:
1971				out[-1] += [f"{r[f[0]]:{f[1]}}"]
1972			out[-1] += [
1973				f"{r['d13Cwg_VPDB']:.3f}",
1974				f"{r['d18Owg_VSMOW']:.3f}",
1975				f"{r['d45']:.6f}",
1976				f"{r['d46']:.6f}",
1977				f"{r['d47']:.6f}",
1978				f"{r['d48']:.6f}",
1979				f"{r['d49']:.6f}",
1980				f"{r['d13C_VPDB']:.6f}",
1981				f"{r['d18O_VSMOW']:.6f}",
1982				f"{r['D47raw']:.6f}",
1983				f"{r['D48raw']:.6f}",
1984				f"{r['D49raw']:.6f}",
1985				f"{r[f'D{self._4x}']:.6f}"
1986				]
1987		if save_to_file:
1988			if not os.path.exists(dir):
1989				os.makedirs(dir)
1990			if filename is None:
1991				filename = f'D{self._4x}_analyses.csv'
1992			with open(f'{dir}/{filename}', 'w') as fid:
1993				fid.write(make_csv(out))
1994		if print_out:
1995			self.msg('\n' + pretty_table(out))
1996		return out
1997
1998	@make_verbal
1999	def covar_table(
2000		self,
2001		correl = False,
2002		dir = 'output',
2003		filename = None,
2004		save_to_file = True,
2005		print_out = True,
2006		output = None,
2007		):
2008		'''
2009		Print out, save to disk and/or return the variance-covariance matrix of D4x
2010		for all unknown samples.
2011
2012		**Parameters**
2013
2014		+ `dir`: the directory in which to save the csv
2015		+ `filename`: the name of the csv file to write to
2016		+ `save_to_file`: whether to save the csv
2017		+ `print_out`: whether to print out the matrix
2018		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
2019		    if set to `'raw'`: return a list of list of strings
2020		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
2021		'''
2022		samples = sorted([u for u in self.unknowns])
2023		out = [[''] + samples]
2024		for s1 in samples:
2025			out.append([s1])
2026			for s2 in samples:
2027				if correl:
2028					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
2029				else:
2030					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
2031
2032		if save_to_file:
2033			if not os.path.exists(dir):
2034				os.makedirs(dir)
2035			if filename is None:
2036				if correl:
2037					filename = f'D{self._4x}_correl.csv'
2038				else:
2039					filename = f'D{self._4x}_covar.csv'
2040			with open(f'{dir}/{filename}', 'w') as fid:
2041				fid.write(make_csv(out))
2042		if print_out:
2043			self.msg('\n'+pretty_table(out))
2044		if output == 'raw':
2045			return out
2046		elif output == 'pretty':
2047			return pretty_table(out)
2048
2049	@make_verbal
2050	def table_of_samples(
2051		self,
2052		dir = 'output',
2053		filename = None,
2054		save_to_file = True,
2055		print_out = True,
2056		output = None,
2057		):
2058		'''
2059		Print out, save to disk and/or return a table of samples.
2060
2061		**Parameters**
2062
2063		+ `dir`: the directory in which to save the csv
2064		+ `filename`: the name of the csv file to write to
2065		+ `save_to_file`: whether to save the csv
2066		+ `print_out`: whether to print out the table
2067		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
2068		    if set to `'raw'`: return a list of list of strings
2069		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
2070		'''
2071
2072		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
2073		for sample in self.anchors:
2074			out += [[
2075				f"{sample}",
2076				f"{self.samples[sample]['N']}",
2077				f"{self.samples[sample]['d13C_VPDB']:.2f}",
2078				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
2079				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
2080				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
2081				]]
2082		for sample in self.unknowns:
2083			out += [[
2084				f"{sample}",
2085				f"{self.samples[sample]['N']}",
2086				f"{self.samples[sample]['d13C_VPDB']:.2f}",
2087				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
2088				f"{self.samples[sample][f'D{self._4x}']:.4f}",
2089				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
2090				f{self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
2091				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
2092				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
2093				]]
2094		if save_to_file:
2095			if not os.path.exists(dir):
2096				os.makedirs(dir)
2097			if filename is None:
2098				filename = f'D{self._4x}_samples.csv'
2099			with open(f'{dir}/{filename}', 'w') as fid:
2100				fid.write(make_csv(out))
2101		if print_out:
2102			self.msg('\n'+pretty_table(out))
2103		if output == 'raw':
2104			return out
2105		elif output == 'pretty':
2106			return pretty_table(out)
2107
2108
2109	def plot_sessions(self, dir = 'output', figsize = (8,8)):
2110		'''
2111		Generate session plots and save them to disk.
2112
2113		**Parameters**
2114
2115		+ `dir`: the directory in which to save the plots
2116		+ `figsize`: the width and height (in inches) of each plot
2117		'''
2118		if not os.path.exists(dir):
2119			os.makedirs(dir)
2120
2121		for session in self.sessions:
2122			sp = self.plot_single_session(session, xylimits = 'constant')
2123			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
2124			ppl.close(sp.fig)
2125
2126
2127	@make_verbal
2128	def consolidate_samples(self):
2129		'''
2130		Compile various statistics for each sample.
2131
2132		For each anchor sample:
2133
2134		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
2135		+ `SE_D47` or `SE_D48`: set to zero by definition
2136
2137		For each unknown sample:
2138
2139		+ `D47` or `D48`: the standardized Δ4x value for this unknown
2140		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown
2141
2142		For each anchor and unknown:
2143
2144		+ `N`: the total number of analyses of this sample
2145		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
2146		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
2147		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
2148		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
2149		variance, indicating whether the Δ4x repeatability this sample differs significantly from
2150		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
2151		'''
2152		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
2153		for sample in self.samples:
2154			self.samples[sample]['N'] = len(self.samples[sample]['data'])
2155			if self.samples[sample]['N'] > 1:
2156				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
2157
2158			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
2159			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
2160
2161			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
2162			if len(D4x_pop) > 2:
2163				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
2164
2165		if self.standardization_method == 'pooled':
2166			for sample in self.anchors:
2167				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
2168				self.samples[sample][f'SE_D{self._4x}'] = 0.
2169			for sample in self.unknowns:
2170				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
2171				try:
2172					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
2173				except ValueError:
2174					# when `sample` is constrained by self.standardize(constraints = {...}),
2175					# it is no longer listed in self.standardization.var_names.
2176					# Temporary fix: define SE as zero for now
2177					self.samples[sample][f'SE_D4{self._4x}'] = 0.
2178
2179		elif self.standardization_method == 'indep_sessions':
2180			for sample in self.anchors:
2181				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
2182				self.samples[sample][f'SE_D{self._4x}'] = 0.
2183			for sample in self.unknowns:
2184				self.msg(f'Consolidating sample {sample}')
2185				self.unknowns[sample][f'session_D{self._4x}'] = {}
2186				session_avg = []
2187				for session in self.sessions:
2188					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
2189					if sdata:
2190						self.msg(f'{sample} found in session {session}')
2191						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
2192						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
2193						# !! TODO: sigma_s below does not account for temporal changes in standardization error
2194						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
2195						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
2196						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
2197						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
2198				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
2199				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
2200				wsum = sum([weights[s] for s in weights])
2201				for s in weights:
2202					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]
2203
2204
2205	def consolidate_sessions(self):
2206		'''
2207		Compute various statistics for each session.
2208
2209		+ `Na`: Number of anchor analyses in the session
2210		+ `Nu`: Number of unknown analyses in the session
2211		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
2212		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
2213		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
2214		+ `a`: scrambling factor
2215		+ `b`: compositional slope
2216		+ `c`: WG offset
2217		+ `SE_a`: Model stadard erorr of `a`
2218		+ `SE_b`: Model stadard erorr of `b`
2219		+ `SE_c`: Model stadard erorr of `c`
2220		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
2221		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
2222		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
2223		+ `a2`: scrambling factor drift
2224		+ `b2`: compositional slope drift
2225		+ `c2`: WG offset drift
2226		+ `Np`: Number of standardization parameters to fit
2227		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
2228		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
2229		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
2230		'''
2231		for session in self.sessions:
2232			if 'd13Cwg_VPDB' not in self.sessions[session]:
2233				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
2234			if 'd18Owg_VSMOW' not in self.sessions[session]:
2235				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
2236			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
2237			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
2238
2239			self.msg(f'Computing repeatabilities for session {session}')
2240			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
2241			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
2242			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
2243
2244		if self.standardization_method == 'pooled':
2245			for session in self.sessions:
2246
2247				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
2248				i = self.standardization.var_names.index(f'a_{pf(session)}')
2249				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
2250
2251				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
2252				i = self.standardization.var_names.index(f'b_{pf(session)}')
2253				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
2254
2255				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
2256				i = self.standardization.var_names.index(f'c_{pf(session)}')
2257				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
2258
2259				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
2260				if self.sessions[session]['scrambling_drift']:
2261					i = self.standardization.var_names.index(f'a2_{pf(session)}')
2262					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
2263				else:
2264					self.sessions[session]['SE_a2'] = 0.
2265
2266				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
2267				if self.sessions[session]['slope_drift']:
2268					i = self.standardization.var_names.index(f'b2_{pf(session)}')
2269					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
2270				else:
2271					self.sessions[session]['SE_b2'] = 0.
2272
2273				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
2274				if self.sessions[session]['wg_drift']:
2275					i = self.standardization.var_names.index(f'c2_{pf(session)}')
2276					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
2277				else:
2278					self.sessions[session]['SE_c2'] = 0.
2279
2280				i = self.standardization.var_names.index(f'a_{pf(session)}')
2281				j = self.standardization.var_names.index(f'b_{pf(session)}')
2282				k = self.standardization.var_names.index(f'c_{pf(session)}')
2283				CM = np.zeros((6,6))
2284				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
2285				try:
2286					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
2287					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
2288					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
2289					try:
2290						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
2291						CM[3,4] = self.standardization.covar[i2,j2]
2292						CM[4,3] = self.standardization.covar[j2,i2]
2293					except ValueError:
2294						pass
2295					try:
2296						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2297						CM[3,5] = self.standardization.covar[i2,k2]
2298						CM[5,3] = self.standardization.covar[k2,i2]
2299					except ValueError:
2300						pass
2301				except ValueError:
2302					pass
2303				try:
2304					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
2305					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
2306					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
2307					try:
2308						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2309						CM[4,5] = self.standardization.covar[j2,k2]
2310						CM[5,4] = self.standardization.covar[k2,j2]
2311					except ValueError:
2312						pass
2313				except ValueError:
2314					pass
2315				try:
2316					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2317					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
2318					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
2319				except ValueError:
2320					pass
2321
2322				self.sessions[session]['CM'] = CM
2323
2324		elif self.standardization_method == 'indep_sessions':
2325			pass # Not implemented yet
2326
2327
2328	@make_verbal
2329	def repeatabilities(self):
2330		'''
2331		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
2332		(for all samples, for anchors, and for unknowns).
2333		'''
2334		self.msg('Computing reproducibilities for all sessions')
2335
2336		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
2337		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
2338		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
2339		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
2340		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
2341
2342
2343	@make_verbal
2344	def consolidate(self, tables = True, plots = True):
2345		'''
2346		Collect information about samples, sessions and repeatabilities.
2347		'''
2348		self.consolidate_samples()
2349		self.consolidate_sessions()
2350		self.repeatabilities()
2351
2352		if tables:
2353			self.summary()
2354			self.table_of_sessions()
2355			self.table_of_analyses()
2356			self.table_of_samples()
2357
2358		if plots:
2359			self.plot_sessions()
2360
2361
2362	@make_verbal
2363	def rmswd(self,
2364		samples = 'all samples',
2365		sessions = 'all sessions',
2366		):
2367		'''
2368		Compute the χ2, root mean squared weighted deviation
2369		(i.e. reduced χ2), and corresponding degrees of freedom of the
2370		Δ4x values for samples in `samples` and sessions in `sessions`.
2371		
2372		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
2373		'''
2374		if samples == 'all samples':
2375			mysamples = [k for k in self.samples]
2376		elif samples == 'anchors':
2377			mysamples = [k for k in self.anchors]
2378		elif samples == 'unknowns':
2379			mysamples = [k for k in self.unknowns]
2380		else:
2381			mysamples = samples
2382
2383		if sessions == 'all sessions':
2384			sessions = [k for k in self.sessions]
2385
2386		chisq, Nf = 0, 0
2387		for sample in mysamples :
2388			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2389			if len(G) > 1 :
2390				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
2391				Nf += (len(G) - 1)
2392				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
2393		r = (chisq / Nf)**.5 if Nf > 0 else 0
2394		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
2395		return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}
2396
2397	
2398	@make_verbal
2399	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
2400		'''
2401		Compute the repeatability of `[r[key] for r in self]`
2402		'''
2403		# NB: it's debatable whether rD47 should be computed
2404		# with Nf = len(self)-len(self.samples) instead of
2405		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
2406
2407		if samples == 'all samples':
2408			mysamples = [k for k in self.samples]
2409		elif samples == 'anchors':
2410			mysamples = [k for k in self.anchors]
2411		elif samples == 'unknowns':
2412			mysamples = [k for k in self.unknowns]
2413		else:
2414			mysamples = samples
2415
2416		if sessions == 'all sessions':
2417			sessions = [k for k in self.sessions]
2418
2419		if key in ['D47', 'D48']:
2420			chisq, Nf = 0, 0
2421			for sample in mysamples :
2422				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2423				if len(X) > 1 :
2424					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
2425					if sample in self.unknowns:
2426						Nf += len(X) - 1
2427					else:
2428						Nf += len(X)
2429			if samples in ['anchors', 'all samples']:
2430				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
2431			r = (chisq / Nf)**.5 if Nf > 0 else 0
2432
2433		else: # if key not in ['D47', 'D48']
2434			chisq, Nf = 0, 0
2435			for sample in mysamples :
2436				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2437				if len(X) > 1 :
2438					Nf += len(X) - 1
2439					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
2440			r = (chisq / Nf)**.5 if Nf > 0 else 0
2441
2442		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
2443		return r
2444
2445	def sample_average(self, samples, weights = 'equal', normalize = True):
2446		'''
2447		Weighted average Δ4x value of a group of samples, accounting for covariance.
2448
2449		Returns the weighed average Δ4x value and associated SE
2450		of a group of samples. Weights are equal by default. If `normalize` is
2451		true, `weights` will be rescaled so that their sum equals 1.
2452
2453		**Examples**
2454
2455		```python
2456		self.sample_average(['X','Y'], [1, 2])
2457		```
2458
2459		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
2460		where Δ4x(X) and Δ4x(Y) are the average Δ4x
2461		values of samples X and Y, respectively.
2462
2463		```python
2464		self.sample_average(['X','Y'], [1, -1], normalize = False)
2465		```
2466
2467		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
2468		'''
2469		if weights == 'equal':
2470			weights = [1/len(samples)] * len(samples)
2471
2472		if normalize:
2473			s = sum(weights)
2474			if s:
2475				weights = [w/s for w in weights]
2476
2477		try:
2478# 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
2479# 			C = self.standardization.covar[indices,:][:,indices]
2480			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
2481			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
2482			return correlated_sum(X, C, weights)
2483		except ValueError:
2484			return (0., 0.)
2485
2486
2487	def sample_D4x_covar(self, sample1, sample2 = None):
2488		'''
2489		Covariance between Δ4x values of samples
2490
2491		Returns the error covariance between the average Δ4x values of two
2492		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
2493		returns the Δ4x variance for that sample.
2494		'''
2495		if sample2 is None:
2496			sample2 = sample1
2497		if self.standardization_method == 'pooled':
2498			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
2499			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
2500			return self.standardization.covar[i, j]
2501		elif self.standardization_method == 'indep_sessions':
2502			if sample1 == sample2:
2503				return self.samples[sample1][f'SE_D{self._4x}']**2
2504			else:
2505				c = 0
2506				for session in self.sessions:
2507					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
2508					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
2509					if sdata1 and sdata2:
2510						a = self.sessions[session]['a']
2511						# !! TODO: CM below does not account for temporal changes in standardization parameters
2512						CM = self.sessions[session]['CM'][:3,:3]
2513						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
2514						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
2515						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
2516						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
2517						c += (
2518							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
2519							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
2520							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
2521							@ CM
2522							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
2523							) / a**2
2524				return float(c)
2525
2526	def sample_D4x_correl(self, sample1, sample2 = None):
2527		'''
2528		Correlation between Δ4x errors of samples
2529
2530		Returns the error correlation between the average Δ4x values of two samples.
2531		'''
2532		if sample2 is None or sample2 == sample1:
2533			return 1.
2534		return (
2535			self.sample_D4x_covar(sample1, sample2)
2536			/ self.unknowns[sample1][f'SE_D{self._4x}']
2537			/ self.unknowns[sample2][f'SE_D{self._4x}']
2538			)
2539
2540	def plot_single_session(self,
2541		session,
2542		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
2543		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
2544		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
2545		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
2546		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
2547		xylimits = 'free', # | 'constant'
2548		x_label = None,
2549		y_label = None,
2550		error_contour_interval = 'auto',
2551		fig = 'new',
2552		):
2553		'''
2554		Generate plot for a single session
2555		'''
2556		if x_label is None:
2557			x_label = f'δ$_{{{self._4x}}}$ (‰)'
2558		if y_label is None:
2559			y_label = f'Δ$_{{{self._4x}}}$ (‰)'
2560
2561		out = _SessionPlot()
2562		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
2563		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
2564		
2565		if fig == 'new':
2566			out.fig = ppl.figure(figsize = (6,6))
2567			ppl.subplots_adjust(.1,.1,.9,.9)
2568
2569		out.anchor_analyses, = ppl.plot(
2570			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
2571			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
2572			**kw_plot_anchors)
2573		out.unknown_analyses, = ppl.plot(
2574			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
2575			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
2576			**kw_plot_unknowns)
2577		out.anchor_avg = ppl.plot(
2578			np.array([ np.array([
2579				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
2580				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
2581				]) for sample in anchors]).T,
2582			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
2583			**kw_plot_anchor_avg)
2584		out.unknown_avg = ppl.plot(
2585			np.array([ np.array([
2586				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
2587				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
2588				]) for sample in unknowns]).T,
2589			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
2590			**kw_plot_unknown_avg)
2591		if xylimits == 'constant':
2592			x = [r[f'd{self._4x}'] for r in self]
2593			y = [r[f'D{self._4x}'] for r in self]
2594			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
2595			w, h = x2-x1, y2-y1
2596			x1 -= w/20
2597			x2 += w/20
2598			y1 -= h/20
2599			y2 += h/20
2600			ppl.axis([x1, x2, y1, y2])
2601		elif xylimits == 'free':
2602			x1, x2, y1, y2 = ppl.axis()
2603		else:
2604			x1, x2, y1, y2 = ppl.axis(xylimits)
2605				
2606		if error_contour_interval != 'none':
2607			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
2608			XI,YI = np.meshgrid(xi, yi)
2609			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
2610			if error_contour_interval == 'auto':
2611				rng = np.max(SI) - np.min(SI)
2612				if rng <= 0.01:
2613					cinterval = 0.001
2614				elif rng <= 0.03:
2615					cinterval = 0.004
2616				elif rng <= 0.1:
2617					cinterval = 0.01
2618				elif rng <= 0.3:
2619					cinterval = 0.03
2620				elif rng <= 1.:
2621					cinterval = 0.1
2622				else:
2623					cinterval = 0.5
2624			else:
2625				cinterval = error_contour_interval
2626
2627			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
2628			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
2629			out.clabel = ppl.clabel(out.contour)
2630
2631		ppl.xlabel(x_label)
2632		ppl.ylabel(y_label)
2633		ppl.title(session, weight = 'bold')
2634		ppl.grid(alpha = .2)
2635		out.ax = ppl.gca()		
2636
2637		return out
2638
2639	def plot_residuals(
2640		self,
2641		hist = False,
2642		binwidth = 2/3,
2643		dir = 'output',
2644		filename = None,
2645		highlight = [],
2646		colors = None,
2647		figsize = None,
2648		):
2649		'''
2650		Plot residuals of each analysis as a function of time (actually, as a function of
2651		the order of analyses in the `D4xdata` object)
2652
2653		+ `hist`: whether to add a histogram of residuals
2654		+ `histbins`: specify bin edges for the histogram
2655		+ `dir`: the directory in which to save the plot
2656		+ `highlight`: a list of samples to highlight
2657		+ `colors`: a dict of `{<sample>: <color>}` for all samples
2658		+ `figsize`: (width, height) of figure
2659		'''
2660		# Layout
2661		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
2662		if hist:
2663			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
2664			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
2665		else:
2666			ppl.subplots_adjust(.08,.05,.78,.8)
2667			ax1 = ppl.subplot(111)
2668		
2669		# Colors
2670		N = len(self.anchors)
2671		if colors is None:
2672			if len(highlight) > 0:
2673				Nh = len(highlight)
2674				if Nh == 1:
2675					colors = {highlight[0]: (0,0,0)}
2676				elif Nh == 3:
2677					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
2678				elif Nh == 4:
2679					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
2680				else:
2681					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
2682			else:
2683				if N == 3:
2684					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
2685				elif N == 4:
2686					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
2687				else:
2688					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}
2689
2690		ppl.sca(ax1)
2691		
2692		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)
2693
2694		session = self[0]['Session']
2695		x1 = 0
2696# 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
2697		x_sessions = {}
2698		one_or_more_singlets = False
2699		one_or_more_multiplets = False
2700		multiplets = set()
2701		for k,r in enumerate(self):
2702			if r['Session'] != session:
2703				x2 = k-1
2704				x_sessions[session] = (x1+x2)/2
2705				ppl.axvline(k - 0.5, color = 'k', lw = .5)
2706				session = r['Session']
2707				x1 = k
2708			singlet = len(self.samples[r['Sample']]['data']) == 1
2709			if not singlet:
2710				multiplets.add(r['Sample'])
2711			if r['Sample'] in self.unknowns:
2712				if singlet:
2713					one_or_more_singlets = True
2714				else:
2715					one_or_more_multiplets = True
2716			kw = dict(
2717				marker = 'x' if singlet else '+',
2718				ms = 4 if singlet else 5,
2719				ls = 'None',
2720				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
2721				mew = 1,
2722				alpha = 0.2 if singlet else 1,
2723				)
2724			if highlight and r['Sample'] not in highlight:
2725				kw['alpha'] = 0.2
2726			ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
2727		x2 = k
2728		x_sessions[session] = (x1+x2)/2
2729
2730		ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
2731		ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
2732		if not hist:
2733			ppl.text(len(self), self.repeatability['r_D47']*1000, f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
2734			ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f"   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center')
2735
2736		xmin, xmax, ymin, ymax = ppl.axis()
2737		for s in x_sessions:
2738			ppl.text(
2739				x_sessions[s],
2740				ymax +1,
2741				s,
2742				va = 'bottom',
2743				**(
2744					dict(ha = 'center')
2745					if len(self.sessions[s]['data']) > (0.15 * len(self))
2746					else dict(ha = 'left', rotation = 45)
2747					)
2748				)
2749
2750		if hist:
2751			ppl.sca(ax2)
2752
2753		for s in colors:
2754			kw['marker'] = '+'
2755			kw['ms'] = 5
2756			kw['mec'] = colors[s]
2757			kw['label'] = s
2758			kw['alpha'] = 1
2759			ppl.plot([], [], **kw)
2760
2761		kw['mec'] = (0,0,0)
2762
2763		if one_or_more_singlets:
2764			kw['marker'] = 'x'
2765			kw['ms'] = 4
2766			kw['alpha'] = .2
2767			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
2768			ppl.plot([], [], **kw)
2769
2770		if one_or_more_multiplets:
2771			kw['marker'] = '+'
2772			kw['ms'] = 4
2773			kw['alpha'] = 1
2774			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
2775			ppl.plot([], [], **kw)
2776
2777		if hist:
2778			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
2779		else:
2780			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
2781		leg.set_zorder(-1000)
2782
2783		ppl.sca(ax1)
2784
2785		ppl.ylabel('Δ$_{47}$ residuals (ppm)')
2786		ppl.xticks([])
2787		ppl.axis([-1, len(self), None, None])
2788
2789		if hist:
2790			ppl.sca(ax2)
2791			X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self if r['Sample'] in multiplets]
2792			ppl.hist(
2793				X,
2794				orientation = 'horizontal',
2795				histtype = 'stepfilled',
2796				ec = [.4]*3,
2797				fc = [.25]*3,
2798				alpha = .25,
2799				bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
2800				)
2801			ppl.axis([None, None, ymin, ymax])
2802			ppl.text(0, 0,
2803				f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm\n   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
2804				size = 8,
2805				alpha = 1,
2806				va = 'center',
2807				ha = 'left',
2808				)
2809
2810			ppl.xticks([])
2811			ppl.yticks([])
2812# 			ax2.spines['left'].set_visible(False)
2813			ax2.spines['right'].set_visible(False)
2814			ax2.spines['top'].set_visible(False)
2815			ax2.spines['bottom'].set_visible(False)
2816
2817
2818		if not os.path.exists(dir):
2819			os.makedirs(dir)
2820		if filename is None:
2821			return fig
2822		elif filename == '':
2823			filename = f'D{self._4x}_residuals.pdf'
2824		ppl.savefig(f'{dir}/{filename}')
2825		ppl.close(fig)
2826				
2827
2828	def simulate(self, *args, **kwargs):
2829		'''
2830		Legacy function with warning message pointing to `virtual_data()`
2831		'''
2832		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
2833
2834	def plot_distribution_of_analyses(
2835		self,
2836		dir = 'output',
2837		filename = None,
2838		vs_time = False,
2839		figsize = (6,4),
2840		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
2841		output = None,
2842		):
2843		'''
2844		Plot temporal distribution of all analyses in the data set.
2845		
2846		**Parameters**
2847
2848		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
2849		'''
2850
2851		asamples = [s for s in self.anchors]
2852		usamples = [s for s in self.unknowns]
2853		if output is None or output == 'fig':
2854			fig = ppl.figure(figsize = figsize)
2855			ppl.subplots_adjust(*subplots_adjust)
2856		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
2857		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
2858		Xmax += (Xmax-Xmin)/40
2859		Xmin -= (Xmax-Xmin)/41
2860		for k, s in enumerate(asamples + usamples):
2861			if vs_time:
2862				X = [r['TimeTag'] for r in self if r['Sample'] == s]
2863			else:
2864				X = [x for x,r in enumerate(self) if r['Sample'] == s]
2865			Y = [-k for x in X]
2866			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
2867			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
2868			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
2869		ppl.axis([Xmin, Xmax, -k-1, 1])
2870		ppl.xlabel('\ntime')
2871		ppl.gca().annotate('',
2872			xy = (0.6, -0.02),
2873			xycoords = 'axes fraction',
2874			xytext = (.4, -0.02), 
2875            arrowprops = dict(arrowstyle = "->", color = 'k'),
2876            )
2877			
2878
2879		x2 = -1
2880		for session in self.sessions:
2881			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
2882			if vs_time:
2883				ppl.axvline(x1, color = 'k', lw = .75)
2884			if x2 > -1:
2885				if not vs_time:
2886					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
2887			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
2888# 			from xlrd import xldate_as_datetime
2889# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
2890			if vs_time:
2891				ppl.axvline(x2, color = 'k', lw = .75)
2892				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
2893			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
2894
2895		ppl.xticks([])
2896		ppl.yticks([])
2897
2898		if output is None:
2899			if not os.path.exists(dir):
2900				os.makedirs(dir)
2901			if filename == None:
2902				filename = f'D{self._4x}_distribution_of_analyses.pdf'
2903			ppl.savefig(f'{dir}/{filename}')
2904			ppl.close(fig)
2905		elif output == 'ax':
2906			return ppl.gca()
2907		elif output == 'fig':
2908			return fig
=======
            
 850class D4xdata(list):
 851	'''
 852	Store and process data for a large set of Δ47 and/or Δ48
 853	analyses, usually comprising more than one analytical session.
 854	'''
 855
 856	### 17O CORRECTION PARAMETERS
 857	R13_VPDB = 0.01118  # (Chang & Li, 1990)
 858	'''
 859	Absolute (13C/12C) ratio of VPDB.
 860	By default equal to 0.01118 ([Chang & Li, 1990](http://www.cnki.com.cn/Article/CJFDTotal-JXTW199004006.htm))
 861	'''
 862
 863	R18_VSMOW = 0.0020052  # (Baertschi, 1976)
 864	'''
 865	Absolute (18O/16C) ratio of VSMOW.
 866	By default equal to 0.0020052 ([Baertschi, 1976](https://doi.org/10.1016/0012-821X(76)90115-1))
 867	'''
 868
 869	LAMBDA_17 = 0.528  # (Barkan & Luz, 2005)
 870	'''
 871	Mass-dependent exponent for triple oxygen isotopes.
 872	By default equal to 0.528 ([Barkan & Luz, 2005](https://doi.org/10.1002/rcm.2250))
 873	'''
 874
 875	R17_VSMOW = 0.00038475  # (Assonov & Brenninkmeijer, 2003, rescaled to R13_VPDB)
 876	'''
 877	Absolute (17O/16C) ratio of VSMOW.
 878	By default equal to 0.00038475
 879	([Assonov & Brenninkmeijer, 2003](https://dx.doi.org/10.1002/rcm.1011),
 880	rescaled to `R13_VPDB`)
 881	'''
 882
 883	R18_VPDB = R18_VSMOW * 1.03092
 884	'''
 885	Absolute (18O/16C) ratio of VPDB.
 886	By definition equal to `R18_VSMOW * 1.03092`.
 887	'''
 888
 889	R17_VPDB = R17_VSMOW * 1.03092 ** LAMBDA_17
 890	'''
 891	Absolute (17O/16C) ratio of VPDB.
 892	By definition equal to `R17_VSMOW * 1.03092 ** LAMBDA_17`.
 893	'''
 894
 895	LEVENE_REF_SAMPLE = 'ETH-3'
 896	'''
 897	After the Δ4x standardization step, each sample is tested to
 898	assess whether the Δ4x variance within all analyses for that
 899	sample differs significantly from that observed for a given reference
 900	sample (using [Levene's test](https://en.wikipedia.org/wiki/Levene%27s_test),
 901	which yields a p-value corresponding to the null hypothesis that the
 902	underlying variances are equal).
 903
 904	`LEVENE_REF_SAMPLE` (by default equal to `'ETH-3'`) specifies which
 905	sample should be used as a reference for this test.
 906	'''
 907
 908	ALPHA_18O_ACID_REACTION = round(np.exp(3.59 / (90 + 273.15) - 1.79e-3), 6)  # (Kim et al., 2007, calcite)
 909	'''
 910	Specifies the 18O/16O fractionation factor generally applicable
 911	to acid reactions in the dataset. Currently used by `D4xdata.wg()`,
 912	`D4xdata.standardize_d13C`, and `D4xdata.standardize_d18O`.
 913
 914	By default equal to 1.008129 (calcite reacted at 90 °C,
 915	[Kim et al., 2007](https://dx.doi.org/10.1016/j.chemgeo.2007.08.005)).
 916	'''
 917
 918	Nominal_d13C_VPDB = {
 919		'ETH-1': 2.02,
 920		'ETH-2': -10.17,
 921		'ETH-3': 1.71,
 922		}	# (Bernasconi et al., 2018)
 923	'''
 924	Nominal δ13C_VPDB values assigned to carbonate standards, used by
 925	`D4xdata.standardize_d13C()`.
 926
 927	By default equal to `{'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71}` after
 928	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
 929	'''
 930
 931	Nominal_d18O_VPDB = {
 932		'ETH-1': -2.19,
 933		'ETH-2': -18.69,
 934		'ETH-3': -1.78,
 935		}	# (Bernasconi et al., 2018)
 936	'''
 937	Nominal δ18O_VPDB values assigned to carbonate standards, used by
 938	`D4xdata.standardize_d18O()`.
 939
 940	By default equal to `{'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78}` after
 941	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
 942	'''
 943
 944	d13C_STANDARDIZATION_METHOD = '2pt'
 945	'''
 946	Method by which to standardize δ13C values:
 947	
 948	+ `none`: do not apply any δ13C standardization.
 949	+ `'1pt'`: within each session, offset all initial δ13C values so as to
 950	minimize the difference between final δ13C_VPDB values and
 951	`Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB` is defined).
 952	+ `'2pt'`: within each session, apply a affine trasformation to all δ13C
 953	values so as to minimize the difference between final δ13C_VPDB
 954	values and `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB`
 955	is defined).
 956	'''
 957
 958	d18O_STANDARDIZATION_METHOD = '2pt'
 959	'''
 960	Method by which to standardize δ18O values:
 961	
 962	+ `none`: do not apply any δ18O standardization.
 963	+ `'1pt'`: within each session, offset all initial δ18O values so as to
 964	minimize the difference between final δ18O_VPDB values and
 965	`Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` is defined).
 966	+ `'2pt'`: within each session, apply a affine trasformation to all δ18O
 967	values so as to minimize the difference between final δ18O_VPDB
 968	values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB`
 969	is defined).
 970	'''
 971
 972	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
 973		'''
 974		**Parameters**
 975
 976		+ `l`: a list of dictionaries, with each dictionary including at least the keys
 977		`Sample`, `d45`, `d46`, and `d47` or `d48`.
 978		+ `mass`: `'47'` or `'48'`
 979		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
 980		+ `session`: define session name for analyses without a `Session` key
 981		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.
 982
 983		Returns a `D4xdata` object derived from `list`.
 984		'''
 985		self._4x = mass
 986		self.verbose = verbose
 987		self.prefix = 'D4xdata'
 988		self.logfile = logfile
 989		list.__init__(self, l)
 990		self.Nf = None
 991		self.repeatability = {}
 992		self.refresh(session = session)
 993
 994
 995	def make_verbal(oldfun):
 996		'''
 997		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
 998		'''
 999		@wraps(oldfun)
1000		def newfun(*args, verbose = '', **kwargs):
1001			myself = args[0]
1002			oldprefix = myself.prefix
1003			myself.prefix = oldfun.__name__
1004			if verbose != '':
1005				oldverbose = myself.verbose
1006				myself.verbose = verbose
1007			out = oldfun(*args, **kwargs)
1008			myself.prefix = oldprefix
1009			if verbose != '':
1010				myself.verbose = oldverbose
1011			return out
1012		return newfun
1013
1014
1015	def msg(self, txt):
1016		'''
1017		Log a message to `self.logfile`, and print it out if `verbose = True`
1018		'''
1019		self.log(txt)
1020		if self.verbose:
1021			print(f'{f"[{self.prefix}]":<16} {txt}')
1022
1023
1024	def vmsg(self, txt):
1025		'''
1026		Log a message to `self.logfile` and print it out
1027		'''
1028		self.log(txt)
1029		print(txt)
1030
1031
1032	def log(self, *txts):
1033		'''
1034		Log a message to `self.logfile`
1035		'''
1036		if self.logfile:
1037			with open(self.logfile, 'a') as fid:
1038				for txt in txts:
1039					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
1040
1041
1042	def refresh(self, session = 'mySession'):
1043		'''
1044		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
1045		'''
1046		self.fill_in_missing_info(session = session)
1047		self.refresh_sessions()
1048		self.refresh_samples()
1049
1050
1051	def refresh_sessions(self):
1052		'''
1053		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
1054		to `False` for all sessions.
1055		'''
1056		self.sessions = {
1057			s: {'data': [r for r in self if r['Session'] == s]}
1058			for s in sorted({r['Session'] for r in self})
1059			}
1060		for s in self.sessions:
1061			self.sessions[s]['scrambling_drift'] = False
1062			self.sessions[s]['slope_drift'] = False
1063			self.sessions[s]['wg_drift'] = False
1064			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
1065			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
1066
1067
1068	def refresh_samples(self):
1069		'''
1070		Define `self.samples`, `self.anchors`, and `self.unknowns`.
1071		'''
1072		self.samples = {
1073			s: {'data': [r for r in self if r['Sample'] == s]}
1074			for s in sorted({r['Sample'] for r in self})
1075			}
1076		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
1077		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
1078
1079
1080	def read(self, filename, sep = '', session = ''):
1081		'''
1082		Read file in csv format to load data into a `D47data` object.
1083
1084		In the csv file, spaces before and after field separators (`','` by default)
1085		are optional. Each line corresponds to a single analysis.
1086
1087		The required fields are:
1088
1089		+ `UID`: a unique identifier
1090		+ `Session`: an identifier for the analytical session
1091		+ `Sample`: a sample identifier
1092		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
1093
1094		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
1095		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
1096		and `d49` are optional, and set to NaN by default.
1097
1098		**Parameters**
1099
1100		+ `fileneme`: the path of the file to read
1101		+ `sep`: csv separator delimiting the fields
1102		+ `session`: set `Session` field to this string for all analyses
1103		'''
1104		with open(filename) as fid:
1105			self.input(fid.read(), sep = sep, session = session)
1106
1107
1108	def input(self, txt, sep = '', session = ''):
1109		'''
1110		Read `txt` string in csv format to load analysis data into a `D47data` object.
1111
1112		In the csv string, spaces before and after field separators (`','` by default)
1113		are optional. Each line corresponds to a single analysis.
1114
1115		The required fields are:
1116
1117		+ `UID`: a unique identifier
1118		+ `Session`: an identifier for the analytical session
1119		+ `Sample`: a sample identifier
1120		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
1121
1122		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
1123		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
1124		and `d49` are optional, and set to NaN by default.
1125
1126		**Parameters**
1127
1128		+ `txt`: the csv string to read
1129		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
1130		whichever appers most often in `txt`.
1131		+ `session`: set `Session` field to this string for all analyses
1132		'''
1133		if sep == '':
1134			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
1135		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
1136		data = [{k: v if k in ['UID', 'Session', 'Sample'] else smart_type(v) for k,v in zip(txt[0], l) if v != ''} for l in txt[1:]]
1137
1138		if session != '':
1139			for r in data:
1140				r['Session'] = session
1141
1142		self += data
1143		self.refresh()
1144
1145
1146	@make_verbal
1147	def wg(self, samples = None, a18_acid = None):
1148		'''
1149		Compute bulk composition of the working gas for each session based on
1150		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
1151		`self.Nominal_d18O_VPDB`.
1152		'''
1153
1154		self.msg('Computing WG composition:')
1155
1156		if a18_acid is None:
1157			a18_acid = self.ALPHA_18O_ACID_REACTION
1158		if samples is None:
1159			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
1160
1161		assert a18_acid, f'Acid fractionation factor should not be zero.'
1162
1163		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
1164		R45R46_standards = {}
1165		for sample in samples:
1166			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
1167			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
1168			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
1169			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
1170			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
1171
1172			C12_s = 1 / (1 + R13_s)
1173			C13_s = R13_s / (1 + R13_s)
1174			C16_s = 1 / (1 + R17_s + R18_s)
1175			C17_s = R17_s / (1 + R17_s + R18_s)
1176			C18_s = R18_s / (1 + R17_s + R18_s)
1177
1178			C626_s = C12_s * C16_s ** 2
1179			C627_s = 2 * C12_s * C16_s * C17_s
1180			C628_s = 2 * C12_s * C16_s * C18_s
1181			C636_s = C13_s * C16_s ** 2
1182			C637_s = 2 * C13_s * C16_s * C17_s
1183			C727_s = C12_s * C17_s ** 2
1184
1185			R45_s = (C627_s + C636_s) / C626_s
1186			R46_s = (C628_s + C637_s + C727_s) / C626_s
1187			R45R46_standards[sample] = (R45_s, R46_s)
1188		
1189		for s in self.sessions:
1190			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
1191			assert db, f'No sample from {samples} found in session "{s}".'
1192# 			dbsamples = sorted({r['Sample'] for r in db})
1193
1194			X = [r['d45'] for r in db]
1195			Y = [R45R46_standards[r['Sample']][0] for r in db]
1196			x1, x2 = np.min(X), np.max(X)
1197
1198			if x1 < x2:
1199				wgcoord = x1/(x1-x2)
1200			else:
1201				wgcoord = 999
1202
1203			if wgcoord < -.5 or wgcoord > 1.5:
1204				# unreasonable to extrapolate to d45 = 0
1205				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
1206			else :
1207				# d45 = 0 is reasonably well bracketed
1208				R45_wg = np.polyfit(X, Y, 1)[1]
1209
1210			X = [r['d46'] for r in db]
1211			Y = [R45R46_standards[r['Sample']][1] for r in db]
1212			x1, x2 = np.min(X), np.max(X)
1213
1214			if x1 < x2:
1215				wgcoord = x1/(x1-x2)
1216			else:
1217				wgcoord = 999
1218
1219			if wgcoord < -.5 or wgcoord > 1.5:
1220				# unreasonable to extrapolate to d46 = 0
1221				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
1222			else :
1223				# d46 = 0 is reasonably well bracketed
1224				R46_wg = np.polyfit(X, Y, 1)[1]
1225
1226			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
1227
1228			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
1229
1230			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
1231			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
1232			for r in self.sessions[s]['data']:
1233				r['d13Cwg_VPDB'] = d13Cwg_VPDB
1234				r['d18Owg_VSMOW'] = d18Owg_VSMOW
1235
1236
1237	def compute_bulk_delta(self, R45, R46, D17O = 0):
1238		'''
1239		Compute δ13C_VPDB and δ18O_VSMOW,
1240		by solving the generalized form of equation (17) from
1241		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
1242		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
1243		solving the corresponding second-order Taylor polynomial.
1244		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
1245		'''
1246
1247		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
1248
1249		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
1250		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
1251		C = 2 * self.R18_VSMOW
1252		D = -R46
1253
1254		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
1255		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
1256		cc = A + B + C + D
1257
1258		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
1259
1260		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
1261		R17 = K * R18 ** self.LAMBDA_17
1262		R13 = R45 - 2 * R17
1263
1264		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
1265
1266		return d13C_VPDB, d18O_VSMOW
1267
1268
1269	@make_verbal
1270	def crunch(self, verbose = ''):
1271		'''
1272		Compute bulk composition and raw clumped isotope anomalies for all analyses.
1273		'''
1274		for r in self:
1275			self.compute_bulk_and_clumping_deltas(r)
1276		self.standardize_d13C()
1277		self.standardize_d18O()
1278		self.msg(f"Crunched {len(self)} analyses.")
1279
1280
1281	def fill_in_missing_info(self, session = 'mySession'):
1282		'''
1283		Fill in optional fields with default values
1284		'''
1285		for i,r in enumerate(self):
1286			if 'D17O' not in r:
1287				r['D17O'] = 0.
1288			if 'UID' not in r:
1289				r['UID'] = f'{i+1}'
1290			if 'Session' not in r:
1291				r['Session'] = session
1292			for k in ['d47', 'd48', 'd49']:
1293				if k not in r:
1294					r[k] = np.nan
1295
1296
1297	def standardize_d13C(self):
1298		'''
1299		Perform δ13C standadization within each session `s` according to
1300		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
1301		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
1302		may be redefined abitrarily at a later stage.
1303		'''
1304		for s in self.sessions:
1305			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
1306				XY = [(r['d13C_VPDB'], self.Nominal_d13C_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d13C_VPDB]
1307				X,Y = zip(*XY)
1308				if self.sessions[s]['d13C_standardization_method'] == '1pt':
1309					offset = np.mean(Y) - np.mean(X)
1310					for r in self.sessions[s]['data']:
1311						r['d13C_VPDB'] += offset				
1312				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
1313					a,b = np.polyfit(X,Y,1)
1314					for r in self.sessions[s]['data']:
1315						r['d13C_VPDB'] = a * r['d13C_VPDB'] + b
1316
1317	def standardize_d18O(self):
1318		'''
1319		Perform δ18O standadization within each session `s` according to
1320		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
1321		which is defined by default by `D47data.refresh_sessions()`as equal to
1322		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
1323		'''
1324		for s in self.sessions:
1325			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
1326				XY = [(r['d18O_VSMOW'], self.Nominal_d18O_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d18O_VPDB]
1327				X,Y = zip(*XY)
1328				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
1329				if self.sessions[s]['d18O_standardization_method'] == '1pt':
1330					offset = np.mean(Y) - np.mean(X)
1331					for r in self.sessions[s]['data']:
1332						r['d18O_VSMOW'] += offset				
1333				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
1334					a,b = np.polyfit(X,Y,1)
1335					for r in self.sessions[s]['data']:
1336						r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b
1337	
1338
1339	def compute_bulk_and_clumping_deltas(self, r):
1340		'''
1341		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
1342		'''
1343
1344		# Compute working gas R13, R18, and isobar ratios
1345		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
1346		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
1347		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
1348
1349		# Compute analyte isobar ratios
1350		R45 = (1 + r['d45'] / 1000) * R45_wg
1351		R46 = (1 + r['d46'] / 1000) * R46_wg
1352		R47 = (1 + r['d47'] / 1000) * R47_wg
1353		R48 = (1 + r['d48'] / 1000) * R48_wg
1354		R49 = (1 + r['d49'] / 1000) * R49_wg
1355
1356		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
1357		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
1358		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
1359
1360		# Compute stochastic isobar ratios of the analyte
1361		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
1362			R13, R18, D17O = r['D17O']
1363		)
1364
1365		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
1366		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
1367		if (R45 / R45stoch - 1) > 5e-8:
1368			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
1369		if (R46 / R46stoch - 1) > 5e-8:
1370			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
1371
1372		# Compute raw clumped isotope anomalies
1373		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
1374		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
1375		r['D49raw'] = 1000 * (R49 / R49stoch - 1)
1376
1377
1378	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
1379		'''
1380		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
1381		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
1382		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
1383		'''
1384
1385		# Compute R17
1386		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
1387
1388		# Compute isotope concentrations
1389		C12 = (1 + R13) ** -1
1390		C13 = C12 * R13
1391		C16 = (1 + R17 + R18) ** -1
1392		C17 = C16 * R17
1393		C18 = C16 * R18
1394
1395		# Compute stochastic isotopologue concentrations
1396		C626 = C16 * C12 * C16
1397		C627 = C16 * C12 * C17 * 2
1398		C628 = C16 * C12 * C18 * 2
1399		C636 = C16 * C13 * C16
1400		C637 = C16 * C13 * C17 * 2
1401		C638 = C16 * C13 * C18 * 2
1402		C727 = C17 * C12 * C17
1403		C728 = C17 * C12 * C18 * 2
1404		C737 = C17 * C13 * C17
1405		C738 = C17 * C13 * C18 * 2
1406		C828 = C18 * C12 * C18
1407		C838 = C18 * C13 * C18
1408
1409		# Compute stochastic isobar ratios
1410		R45 = (C636 + C627) / C626
1411		R46 = (C628 + C637 + C727) / C626
1412		R47 = (C638 + C728 + C737) / C626
1413		R48 = (C738 + C828) / C626
1414		R49 = C838 / C626
1415
1416		# Account for stochastic anomalies
1417		R47 *= 1 + D47 / 1000
1418		R48 *= 1 + D48 / 1000
1419		R49 *= 1 + D49 / 1000
1420
1421		# Return isobar ratios
1422		return R45, R46, R47, R48, R49
1423
1424
1425	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
1426		'''
1427		Split unknown samples by UID (treat all analyses as different samples)
1428		or by session (treat analyses of a given sample in different sessions as
1429		different samples).
1430
1431		**Parameters**
1432
1433		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
1434		+ `grouping`: `by_uid` | `by_session`
1435		'''
1436		if samples_to_split == 'all':
1437			samples_to_split = [s for s in self.unknowns]
1438		gkeys = {'by_uid':'UID', 'by_session':'Session'}
1439		self.grouping = grouping.lower()
1440		if self.grouping in gkeys:
1441			gkey = gkeys[self.grouping]
1442		for r in self:
1443			if r['Sample'] in samples_to_split:
1444				r['Sample_original'] = r['Sample']
1445				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
1446			elif r['Sample'] in self.unknowns:
1447				r['Sample_original'] = r['Sample']
1448		self.refresh_samples()
1449
1450
1451	def unsplit_samples(self, tables = False):
1452		'''
1453		Reverse the effects of `D47data.split_samples()`.
1454		
1455		This should only be used after `D4xdata.standardize()` with `method='pooled'`.
1456		
1457		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
1458		probably use `D4xdata.combine_samples()` instead to reverse the effects of
1459		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
1460		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
1461		that case session-averaged Δ4x values are statistically independent).
1462		'''
1463		unknowns_old = sorted({s for s in self.unknowns})
1464		CM_old = self.standardization.covar[:,:]
1465		VD_old = self.standardization.params.valuesdict().copy()
1466		vars_old = self.standardization.var_names
1467
1468		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
1469
1470		Ns = len(vars_old) - len(unknowns_old)
1471		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
1472		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
1473
1474		W = np.zeros((len(vars_new), len(vars_old)))
1475		W[:Ns,:Ns] = np.eye(Ns)
1476		for u in unknowns_new:
1477			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
1478			if self.grouping == 'by_session':
1479				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
1480			elif self.grouping == 'by_uid':
1481				weights = [1 for s in splits]
1482			sw = sum(weights)
1483			weights = [w/sw for w in weights]
1484			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
1485
1486		CM_new = W @ CM_old @ W.T
1487		V = W @ np.array([[VD_old[k]] for k in vars_old])
1488		VD_new = {k:v[0] for k,v in zip(vars_new, V)}
1489
1490		self.standardization.covar = CM_new
1491		self.standardization.params.valuesdict = lambda : VD_new
1492		self.standardization.var_names = vars_new
1493
1494		for r in self:
1495			if r['Sample'] in self.unknowns:
1496				r['Sample_split'] = r['Sample']
1497				r['Sample'] = r['Sample_original']
1498
1499		self.refresh_samples()
1500		self.consolidate_samples()
1501		self.repeatabilities()
1502
1503		if tables:
1504			self.table_of_analyses()
1505			self.table_of_samples()
1506
1507	def assign_timestamps(self):
1508		'''
1509		Assign a time field `t` of type `float` to each analysis.
1510
1511		If `TimeTag` is one of the data fields, `t` is equal within a given session
1512		to `TimeTag` minus the mean value of `TimeTag` for that session.
1513		Otherwise, `TimeTag` is by default equal to the index of each analysis
1514		in the dataset and `t` is defined as above.
1515		'''
1516		for session in self.sessions:
1517			sdata = self.sessions[session]['data']
1518			try:
1519				t0 = np.mean([r['TimeTag'] for r in sdata])
1520				for r in sdata:
1521					r['t'] = r['TimeTag'] - t0
1522			except KeyError:
1523				t0 = (len(sdata)-1)/2
1524				for t,r in enumerate(sdata):
1525					r['t'] = t - t0
1526
1527
1528	def report(self):
1529		'''
1530		Prints a report on the standardization fit.
1531		Only applicable after `D4xdata.standardize(method='pooled')`.
1532		'''
1533		report_fit(self.standardization)
1534
1535
1536	def combine_samples(self, sample_groups):
1537		'''
1538		Combine analyses of different samples to compute weighted average Δ4x
1539		and new error (co)variances corresponding to the groups defined by the `sample_groups`
1540		dictionary.
1541		
1542		Caution: samples are weighted by number of replicate analyses, which is a
1543		reasonable default behavior but is not always optimal (e.g., in the case of strongly
1544		correlated analytical errors for one or more samples).
1545		
1546		Returns a tuplet of:
1547		
1548		+ the list of group names
1549		+ an array of the corresponding Δ4x values
1550		+ the corresponding (co)variance matrix
1551		
1552		**Parameters**
1553
1554		+ `sample_groups`: a dictionary of the form:
1555		```py
1556		{'group1': ['sample_1', 'sample_2'],
1557		 'group2': ['sample_3', 'sample_4', 'sample_5']}
1558		```
1559		'''
1560		
1561		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
1562		groups = sorted(sample_groups.keys())
1563		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
1564		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
1565		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
1566		W = np.array([
1567			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
1568			for j in groups])
1569		D4x_new = W @ D4x_old
1570		CM_new = W @ CM_old @ W.T
1571
1572		return groups, D4x_new[:,0], CM_new
1573		
1574
1575	@make_verbal
1576	def standardize(self,
1577		method = 'pooled',
1578		weighted_sessions = [],
1579		consolidate = True,
1580		consolidate_tables = False,
1581		consolidate_plots = False,
1582		constraints = {},
1583		):
1584		'''
1585		Compute absolute Δ4x values for all replicate analyses and for sample averages.
1586		If `method` argument is set to `'pooled'`, the standardization processes all sessions
1587		in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
1588		i.e. that their true Δ4x value does not change between sessions,
1589		([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
1590		`'indep_sessions'`, the standardization processes each session independently, based only
1591		on anchors analyses.
1592		'''
1593
1594		self.standardization_method = method
1595		self.assign_timestamps()
1596
1597		if method == 'pooled':
1598			if weighted_sessions:
1599				for session_group in weighted_sessions:
1600					if self._4x == '47':
1601						X = D47data([r for r in self if r['Session'] in session_group])
1602					elif self._4x == '48':
1603						X = D48data([r for r in self if r['Session'] in session_group])
1604					X.Nominal_D4x = self.Nominal_D4x.copy()
1605					X.refresh()
1606					result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
1607					w = np.sqrt(result.redchi)
1608					self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
1609					for r in X:
1610						r[f'wD{self._4x}raw'] *= w
1611			else:
1612				self.msg(f'All D{self._4x}raw weights set to 1 ‰')
1613				for r in self:
1614					r[f'wD{self._4x}raw'] = 1.
1615
1616			params = Parameters()
1617			for k,session in enumerate(self.sessions):
1618				self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
1619				self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
1620				self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
1621				s = pf(session)
1622				params.add(f'a_{s}', value = 0.9)
1623				params.add(f'b_{s}', value = 0.)
1624				params.add(f'c_{s}', value = -0.9)
1625				params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift'])
1626				params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift'])
1627				params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift'])
1628			for sample in self.unknowns:
1629				params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)
1630
1631			for k in constraints:
1632				params[k].expr = constraints[k]
1633
1634			def residuals(p):
1635				R = []
1636				for r in self:
1637					session = pf(r['Session'])
1638					sample = pf(r['Sample'])
1639					if r['Sample'] in self.Nominal_D4x:
1640						R += [ (
1641							r[f'D{self._4x}raw'] - (
1642								p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
1643								+ p[f'b_{session}'] * r[f'd{self._4x}']
1644								+	p[f'c_{session}']
1645								+ r['t'] * (
1646									p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
1647									+ p[f'b2_{session}'] * r[f'd{self._4x}']
1648									+	p[f'c2_{session}']
1649									)
1650								)
1651							) / r[f'wD{self._4x}raw'] ]
1652					else:
1653						R += [ (
1654							r[f'D{self._4x}raw'] - (
1655								p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
1656								+ p[f'b_{session}'] * r[f'd{self._4x}']
1657								+	p[f'c_{session}']
1658								+ r['t'] * (
1659									p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
1660									+ p[f'b2_{session}'] * r[f'd{self._4x}']
1661									+	p[f'c2_{session}']
1662									)
1663								)
1664							) / r[f'wD{self._4x}raw'] ]
1665				return R
1666
1667			M = Minimizer(residuals, params)
1668			result = M.least_squares()
1669			self.Nf = result.nfree
1670			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
1671# 			if self.verbose:
1672# 				report_fit(result)
1673
1674			for r in self:
1675				s = pf(r["Session"])
1676				a = result.params.valuesdict()[f'a_{s}']
1677				b = result.params.valuesdict()[f'b_{s}']
1678				c = result.params.valuesdict()[f'c_{s}']
1679				a2 = result.params.valuesdict()[f'a2_{s}']
1680				b2 = result.params.valuesdict()[f'b2_{s}']
1681				c2 = result.params.valuesdict()[f'c2_{s}']
1682				r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
1683
1684			self.standardization = result
1685
1686			for session in self.sessions:
1687				self.sessions[session]['Np'] = 3
1688				for k in ['scrambling', 'slope', 'wg']:
1689					if self.sessions[session][f'{k}_drift']:
1690						self.sessions[session]['Np'] += 1
1691
1692			if consolidate:
1693				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
1694			return result
1695
1696
1697		elif method == 'indep_sessions':
1698
1699			if weighted_sessions:
1700				for session_group in weighted_sessions:
1701					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
1702					X.Nominal_D4x = self.Nominal_D4x.copy()
1703					X.refresh()
1704					# This is only done to assign r['wD47raw'] for r in X:
1705					X.standardize(method = method, weighted_sessions = [], consolidate = False)
1706					self.msg(f'D{self._4x}raw weights set to {1000*X[0][f"wD{self._4x}raw"]:.1f} ppm for sessions in {session_group}')
1707			else:
1708				self.msg('All weights set to 1 ‰')
1709				for r in self:
1710					r[f'wD{self._4x}raw'] = 1
1711
1712			for session in self.sessions:
1713				s = self.sessions[session]
1714				p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
1715				p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
1716				s['Np'] = sum(p_active)
1717				sdata = s['data']
1718
1719				A = np.array([
1720					[
1721						self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
1722						r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
1723						1 / r[f'wD{self._4x}raw'],
1724						self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
1725						r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
1726						r['t'] / r[f'wD{self._4x}raw']
1727						]
1728					for r in sdata if r['Sample'] in self.anchors
1729					])[:,p_active] # only keep columns for the active parameters
1730				Y = np.array([[r[f'D{self._4x}raw'] / r[f'wD{self._4x}raw']] for r in sdata if r['Sample'] in self.anchors])
1731				s['Na'] = Y.size
1732				CM = linalg.inv(A.T @ A)
1733				bf = (CM @ A.T @ Y).T[0,:]
1734				k = 0
1735				for n,a in zip(p_names, p_active):
1736					if a:
1737						s[n] = bf[k]
1738# 						self.msg(f'{n} = {bf[k]}')
1739						k += 1
1740					else:
1741						s[n] = 0.
1742# 						self.msg(f'{n} = 0.0')
1743
1744				for r in sdata :
1745					a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
1746					r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
1747					r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])
1748
1749				s['CM'] = np.zeros((6,6))
1750				i = 0
1751				k_active = [j for j,a in enumerate(p_active) if a]
1752				for j,a in enumerate(p_active):
1753					if a:
1754						s['CM'][j,k_active] = CM[i,:]
1755						i += 1
1756
1757			if not weighted_sessions:
1758				w = self.rmswd()['rmswd']
1759				for r in self:
1760						r[f'wD{self._4x}'] *= w
1761						r[f'wD{self._4x}raw'] *= w
1762				for session in self.sessions:
1763					self.sessions[session]['CM'] *= w**2
1764
1765			for session in self.sessions:
1766				s = self.sessions[session]
1767				s['SE_a'] = s['CM'][0,0]**.5
1768				s['SE_b'] = s['CM'][1,1]**.5
1769				s['SE_c'] = s['CM'][2,2]**.5
1770				s['SE_a2'] = s['CM'][3,3]**.5
1771				s['SE_b2'] = s['CM'][4,4]**.5
1772				s['SE_c2'] = s['CM'][5,5]**.5
1773
1774			if not weighted_sessions:
1775				self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
1776			else:
1777				self.Nf = 0
1778				for sg in weighted_sessions:
1779					self.Nf += self.rmswd(sessions = sg)['Nf']
1780
1781			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
1782
1783			avgD4x = {
1784				sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
1785				for sample in self.samples
1786				}
1787			chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
1788			rD4x = (chi2/self.Nf)**.5
1789			self.repeatability[f'sigma_{self._4x}'] = rD4x
1790
1791			if consolidate:
1792				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
1793
1794
1795	def standardization_error(self, session, d4x, D4x, t = 0):
1796		'''
1797		Compute standardization error for a given session and
1798		(δ47, Δ47) composition.
1799		'''
1800		a = self.sessions[session]['a']
1801		b = self.sessions[session]['b']
1802		c = self.sessions[session]['c']
1803		a2 = self.sessions[session]['a2']
1804		b2 = self.sessions[session]['b2']
1805		c2 = self.sessions[session]['c2']
1806		CM = self.sessions[session]['CM']
1807
1808		x, y = D4x, d4x
1809		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
1810# 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
1811		dxdy = -(b+b2*t) / (a+a2*t)
1812		dxdz = 1. / (a+a2*t)
1813		dxda = -x / (a+a2*t)
1814		dxdb = -y / (a+a2*t)
1815		dxdc = -1. / (a+a2*t)
1816		dxda2 = -x * a2 / (a+a2*t)
1817		dxdb2 = -y * t / (a+a2*t)
1818		dxdc2 = -t / (a+a2*t)
1819		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
1820		sx = (V @ CM @ V.T) ** .5
1821		return sx
1822
1823
1824	@make_verbal
1825	def summary(self,
1826		dir = 'output',
1827		filename = None,
1828		save_to_file = True,
1829		print_out = True,
1830		):
1831		'''
1832		Print out an/or save to disk a summary of the standardization results.
1833
1834		**Parameters**
1835
1836		+ `dir`: the directory in which to save the table
1837		+ `filename`: the name to the csv file to write to
1838		+ `save_to_file`: whether to save the table to disk
1839		+ `print_out`: whether to print out the table
1840		'''
1841
1842		out = []
1843		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
1844		out += [['N analyses (anchors + unknowns)', f"{len(self)} ({len([r for r in self if r['Sample'] in self.anchors])} + {len([r for r in self if r['Sample'] in self.unknowns])})"]]
1845		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
1846		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
1847		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
1848		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
1849		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
1850		out += [['Model degrees of freedom', f"{self.Nf}"]]
1851		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
1852		out += [['Standardization method', self.standardization_method]]
1853
1854		if save_to_file:
1855			if not os.path.exists(dir):
1856				os.makedirs(dir)
1857			if filename is None:
1858				filename = f'D{self._4x}_summary.csv'
1859			with open(f'{dir}/{filename}', 'w') as fid:
1860				fid.write(make_csv(out))
1861		if print_out:
1862			self.msg('\n' + pretty_table(out, header = 0))
1863
1864
1865	@make_verbal
1866	def table_of_sessions(self,
1867		dir = 'output',
1868		filename = None,
1869		save_to_file = True,
1870		print_out = True,
1871		output = None,
1872		):
1873		'''
1874		Print out an/or save to disk a table of sessions.
1875
1876		**Parameters**
1877
1878		+ `dir`: the directory in which to save the table
1879		+ `filename`: the name to the csv file to write to
1880		+ `save_to_file`: whether to save the table to disk
1881		+ `print_out`: whether to print out the table
1882		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
1883		    if set to `'raw'`: return a list of list of strings
1884		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
1885		'''
1886		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
1887		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
1888		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
1889
1890		out = [['Session','Na','Nu','d13Cwg_VPDB','d18Owg_VSMOW','r_d13C','r_d18O',f'r_D{self._4x}','a ± SE','1e3 x b ± SE','c ± SE']]
1891		if include_a2:
1892			out[-1] += ['a2 ± SE']
1893		if include_b2:
1894			out[-1] += ['b2 ± SE']
1895		if include_c2:
1896			out[-1] += ['c2 ± SE']
1897		for session in self.sessions:
1898			out += [[
1899				session,
1900				f"{self.sessions[session]['Na']}",
1901				f"{self.sessions[session]['Nu']}",
1902				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
1903				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
1904				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
1905				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
1906				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
1907				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
1908				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
1909				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
1910				]]
1911			if include_a2:
1912				if self.sessions[session]['scrambling_drift']:
1913					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
1914				else:
1915					out[-1] += ['']
1916			if include_b2:
1917				if self.sessions[session]['slope_drift']:
1918					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
1919				else:
1920					out[-1] += ['']
1921			if include_c2:
1922				if self.sessions[session]['wg_drift']:
1923					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
1924				else:
1925					out[-1] += ['']
1926
1927		if save_to_file:
1928			if not os.path.exists(dir):
1929				os.makedirs(dir)
1930			if filename is None:
1931				filename = f'D{self._4x}_sessions.csv'
1932			with open(f'{dir}/{filename}', 'w') as fid:
1933				fid.write(make_csv(out))
1934		if print_out:
1935			self.msg('\n' + pretty_table(out))
1936		if output == 'raw':
1937			return out
1938		elif output == 'pretty':
1939			return pretty_table(out)
1940
1941
1942	@make_verbal
1943	def table_of_analyses(
1944		self,
1945		dir = 'output',
1946		filename = None,
1947		save_to_file = True,
1948		print_out = True,
1949		output = None,
1950		):
1951		'''
1952		Print out an/or save to disk a table of analyses.
1953
1954		**Parameters**
1955
1956		+ `dir`: the directory in which to save the table
1957		+ `filename`: the name to the csv file to write to
1958		+ `save_to_file`: whether to save the table to disk
1959		+ `print_out`: whether to print out the table
1960		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
1961		    if set to `'raw'`: return a list of list of strings
1962		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
1963		'''
1964
1965		out = [['UID','Session','Sample']]
1966		extra_fields = [f for f in [('SampleMass','.2f'),('ColdFingerPressure','.1f'),('AcidReactionYield','.3f')] if f[0] in {k for r in self for k in r}]
1967		for f in extra_fields:
1968			out[-1] += [f[0]]
1969		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
1970		for r in self:
1971			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
1972			for f in extra_fields:
1973				out[-1] += [f"{r[f[0]]:{f[1]}}"]
1974			out[-1] += [
1975				f"{r['d13Cwg_VPDB']:.3f}",
1976				f"{r['d18Owg_VSMOW']:.3f}",
1977				f"{r['d45']:.6f}",
1978				f"{r['d46']:.6f}",
1979				f"{r['d47']:.6f}",
1980				f"{r['d48']:.6f}",
1981				f"{r['d49']:.6f}",
1982				f"{r['d13C_VPDB']:.6f}",
1983				f"{r['d18O_VSMOW']:.6f}",
1984				f"{r['D47raw']:.6f}",
1985				f"{r['D48raw']:.6f}",
1986				f"{r['D49raw']:.6f}",
1987				f"{r[f'D{self._4x}']:.6f}"
1988				]
1989		if save_to_file:
1990			if not os.path.exists(dir):
1991				os.makedirs(dir)
1992			if filename is None:
1993				filename = f'D{self._4x}_analyses.csv'
1994			with open(f'{dir}/{filename}', 'w') as fid:
1995				fid.write(make_csv(out))
1996		if print_out:
1997			self.msg('\n' + pretty_table(out))
1998		return out
1999
2000	@make_verbal
2001	def covar_table(
2002		self,
2003		correl = False,
2004		dir = 'output',
2005		filename = None,
2006		save_to_file = True,
2007		print_out = True,
2008		output = None,
2009		):
2010		'''
2011		Print out, save to disk and/or return the variance-covariance matrix of D4x
2012		for all unknown samples.
2013
2014		**Parameters**
2015
2016		+ `dir`: the directory in which to save the csv
2017		+ `filename`: the name of the csv file to write to
2018		+ `save_to_file`: whether to save the csv
2019		+ `print_out`: whether to print out the matrix
2020		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
2021		    if set to `'raw'`: return a list of list of strings
2022		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
2023		'''
2024		samples = sorted([u for u in self.unknowns])
2025		out = [[''] + samples]
2026		for s1 in samples:
2027			out.append([s1])
2028			for s2 in samples:
2029				if correl:
2030					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
2031				else:
2032					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
2033
2034		if save_to_file:
2035			if not os.path.exists(dir):
2036				os.makedirs(dir)
2037			if filename is None:
2038				if correl:
2039					filename = f'D{self._4x}_correl.csv'
2040				else:
2041					filename = f'D{self._4x}_covar.csv'
2042			with open(f'{dir}/{filename}', 'w') as fid:
2043				fid.write(make_csv(out))
2044		if print_out:
2045			self.msg('\n'+pretty_table(out))
2046		if output == 'raw':
2047			return out
2048		elif output == 'pretty':
2049			return pretty_table(out)
2050
2051	@make_verbal
2052	def table_of_samples(
2053		self,
2054		dir = 'output',
2055		filename = None,
2056		save_to_file = True,
2057		print_out = True,
2058		output = None,
2059		):
2060		'''
2061		Print out, save to disk and/or return a table of samples.
2062
2063		**Parameters**
2064
2065		+ `dir`: the directory in which to save the csv
2066		+ `filename`: the name of the csv file to write to
2067		+ `save_to_file`: whether to save the csv
2068		+ `print_out`: whether to print out the table
2069		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
2070		    if set to `'raw'`: return a list of list of strings
2071		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
2072		'''
2073
2074		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
2075		for sample in self.anchors:
2076			out += [[
2077				f"{sample}",
2078				f"{self.samples[sample]['N']}",
2079				f"{self.samples[sample]['d13C_VPDB']:.2f}",
2080				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
2081				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
2082				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
2083				]]
2084		for sample in self.unknowns:
2085			out += [[
2086				f"{sample}",
2087				f"{self.samples[sample]['N']}",
2088				f"{self.samples[sample]['d13C_VPDB']:.2f}",
2089				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
2090				f"{self.samples[sample][f'D{self._4x}']:.4f}",
2091				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
2092				f{self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
2093				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
2094				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
2095				]]
2096		if save_to_file:
2097			if not os.path.exists(dir):
2098				os.makedirs(dir)
2099			if filename is None:
2100				filename = f'D{self._4x}_samples.csv'
2101			with open(f'{dir}/{filename}', 'w') as fid:
2102				fid.write(make_csv(out))
2103		if print_out:
2104			self.msg('\n'+pretty_table(out))
2105		if output == 'raw':
2106			return out
2107		elif output == 'pretty':
2108			return pretty_table(out)
2109
2110
2111	def plot_sessions(self, dir = 'output', figsize = (8,8)):
2112		'''
2113		Generate session plots and save them to disk.
2114
2115		**Parameters**
2116
2117		+ `dir`: the directory in which to save the plots
2118		+ `figsize`: the width and height (in inches) of each plot
2119		'''
2120		if not os.path.exists(dir):
2121			os.makedirs(dir)
2122
2123		for session in self.sessions:
2124			sp = self.plot_single_session(session, xylimits = 'constant')
2125			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
2126			ppl.close(sp.fig)
2127
2128
2129	@make_verbal
2130	def consolidate_samples(self):
2131		'''
2132		Compile various statistics for each sample.
2133
2134		For each anchor sample:
2135
2136		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
2137		+ `SE_D47` or `SE_D48`: set to zero by definition
2138
2139		For each unknown sample:
2140
2141		+ `D47` or `D48`: the standardized Δ4x value for this unknown
2142		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown
2143
2144		For each anchor and unknown:
2145
2146		+ `N`: the total number of analyses of this sample
2147		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
2148		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
2149		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
2150		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
2151		variance, indicating whether the Δ4x repeatability this sample differs significantly from
2152		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
2153		'''
2154		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
2155		for sample in self.samples:
2156			self.samples[sample]['N'] = len(self.samples[sample]['data'])
2157			if self.samples[sample]['N'] > 1:
2158				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
2159
2160			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
2161			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
2162
2163			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
2164			if len(D4x_pop) > 2:
2165				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
2166
2167		if self.standardization_method == 'pooled':
2168			for sample in self.anchors:
2169				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
2170				self.samples[sample][f'SE_D{self._4x}'] = 0.
2171			for sample in self.unknowns:
2172				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
2173				try:
2174					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
2175				except ValueError:
2176					# when `sample` is constrained by self.standardize(constraints = {...}),
2177					# it is no longer listed in self.standardization.var_names.
2178					# Temporary fix: define SE as zero for now
2179					self.samples[sample][f'SE_D4{self._4x}'] = 0.
2180
2181		elif self.standardization_method == 'indep_sessions':
2182			for sample in self.anchors:
2183				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
2184				self.samples[sample][f'SE_D{self._4x}'] = 0.
2185			for sample in self.unknowns:
2186				self.msg(f'Consolidating sample {sample}')
2187				self.unknowns[sample][f'session_D{self._4x}'] = {}
2188				session_avg = []
2189				for session in self.sessions:
2190					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
2191					if sdata:
2192						self.msg(f'{sample} found in session {session}')
2193						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
2194						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
2195						# !! TODO: sigma_s below does not account for temporal changes in standardization error
2196						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
2197						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
2198						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
2199						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
2200				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
2201				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
2202				wsum = sum([weights[s] for s in weights])
2203				for s in weights:
2204					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]
2205
2206
2207	def consolidate_sessions(self):
2208		'''
2209		Compute various statistics for each session.
2210
2211		+ `Na`: Number of anchor analyses in the session
2212		+ `Nu`: Number of unknown analyses in the session
2213		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
2214		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
2215		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
2216		+ `a`: scrambling factor
2217		+ `b`: compositional slope
2218		+ `c`: WG offset
2219		+ `SE_a`: Model stadard erorr of `a`
2220		+ `SE_b`: Model stadard erorr of `b`
2221		+ `SE_c`: Model stadard erorr of `c`
2222		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
2223		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
2224		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
2225		+ `a2`: scrambling factor drift
2226		+ `b2`: compositional slope drift
2227		+ `c2`: WG offset drift
2228		+ `Np`: Number of standardization parameters to fit
2229		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
2230		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
2231		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
2232		'''
2233		for session in self.sessions:
2234			if 'd13Cwg_VPDB' not in self.sessions[session]:
2235				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
2236			if 'd18Owg_VSMOW' not in self.sessions[session]:
2237				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
2238			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
2239			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
2240
2241			self.msg(f'Computing repeatabilities for session {session}')
2242			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
2243			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
2244			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
2245
2246		if self.standardization_method == 'pooled':
2247			for session in self.sessions:
2248
2249				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
2250				i = self.standardization.var_names.index(f'a_{pf(session)}')
2251				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
2252
2253				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
2254				i = self.standardization.var_names.index(f'b_{pf(session)}')
2255				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
2256
2257				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
2258				i = self.standardization.var_names.index(f'c_{pf(session)}')
2259				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
2260
2261				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
2262				if self.sessions[session]['scrambling_drift']:
2263					i = self.standardization.var_names.index(f'a2_{pf(session)}')
2264					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
2265				else:
2266					self.sessions[session]['SE_a2'] = 0.
2267
2268				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
2269				if self.sessions[session]['slope_drift']:
2270					i = self.standardization.var_names.index(f'b2_{pf(session)}')
2271					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
2272				else:
2273					self.sessions[session]['SE_b2'] = 0.
2274
2275				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
2276				if self.sessions[session]['wg_drift']:
2277					i = self.standardization.var_names.index(f'c2_{pf(session)}')
2278					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
2279				else:
2280					self.sessions[session]['SE_c2'] = 0.
2281
2282				i = self.standardization.var_names.index(f'a_{pf(session)}')
2283				j = self.standardization.var_names.index(f'b_{pf(session)}')
2284				k = self.standardization.var_names.index(f'c_{pf(session)}')
2285				CM = np.zeros((6,6))
2286				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
2287				try:
2288					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
2289					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
2290					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
2291					try:
2292						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
2293						CM[3,4] = self.standardization.covar[i2,j2]
2294						CM[4,3] = self.standardization.covar[j2,i2]
2295					except ValueError:
2296						pass
2297					try:
2298						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2299						CM[3,5] = self.standardization.covar[i2,k2]
2300						CM[5,3] = self.standardization.covar[k2,i2]
2301					except ValueError:
2302						pass
2303				except ValueError:
2304					pass
2305				try:
2306					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
2307					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
2308					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
2309					try:
2310						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2311						CM[4,5] = self.standardization.covar[j2,k2]
2312						CM[5,4] = self.standardization.covar[k2,j2]
2313					except ValueError:
2314						pass
2315				except ValueError:
2316					pass
2317				try:
2318					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2319					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
2320					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
2321				except ValueError:
2322					pass
2323
2324				self.sessions[session]['CM'] = CM
2325
2326		elif self.standardization_method == 'indep_sessions':
2327			pass # Not implemented yet
2328
2329
2330	@make_verbal
2331	def repeatabilities(self):
2332		'''
2333		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
2334		(for all samples, for anchors, and for unknowns).
2335		'''
2336		self.msg('Computing reproducibilities for all sessions')
2337
2338		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
2339		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
2340		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
2341		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
2342		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
2343
2344
2345	@make_verbal
2346	def consolidate(self, tables = True, plots = True):
2347		'''
2348		Collect information about samples, sessions and repeatabilities.
2349		'''
2350		self.consolidate_samples()
2351		self.consolidate_sessions()
2352		self.repeatabilities()
2353
2354		if tables:
2355			self.summary()
2356			self.table_of_sessions()
2357			self.table_of_analyses()
2358			self.table_of_samples()
2359
2360		if plots:
2361			self.plot_sessions()
2362
2363
2364	@make_verbal
2365	def rmswd(self,
2366		samples = 'all samples',
2367		sessions = 'all sessions',
2368		):
2369		'''
2370		Compute the χ2, root mean squared weighted deviation
2371		(i.e. reduced χ2), and corresponding degrees of freedom of the
2372		Δ4x values for samples in `samples` and sessions in `sessions`.
2373		
2374		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
2375		'''
2376		if samples == 'all samples':
2377			mysamples = [k for k in self.samples]
2378		elif samples == 'anchors':
2379			mysamples = [k for k in self.anchors]
2380		elif samples == 'unknowns':
2381			mysamples = [k for k in self.unknowns]
2382		else:
2383			mysamples = samples
2384
2385		if sessions == 'all sessions':
2386			sessions = [k for k in self.sessions]
2387
2388		chisq, Nf = 0, 0
2389		for sample in mysamples :
2390			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2391			if len(G) > 1 :
2392				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
2393				Nf += (len(G) - 1)
2394				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
2395		r = (chisq / Nf)**.5 if Nf > 0 else 0
2396		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
2397		return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}
2398
2399	
2400	@make_verbal
2401	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
2402		'''
2403		Compute the repeatability of `[r[key] for r in self]`
2404		'''
2405		# NB: it's debatable whether rD47 should be computed
2406		# with Nf = len(self)-len(self.samples) instead of
2407		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
2408
2409		if samples == 'all samples':
2410			mysamples = [k for k in self.samples]
2411		elif samples == 'anchors':
2412			mysamples = [k for k in self.anchors]
2413		elif samples == 'unknowns':
2414			mysamples = [k for k in self.unknowns]
2415		else:
2416			mysamples = samples
2417
2418		if sessions == 'all sessions':
2419			sessions = [k for k in self.sessions]
2420
2421		if key in ['D47', 'D48']:
2422			chisq, Nf = 0, 0
2423			for sample in mysamples :
2424				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2425				if len(X) > 1 :
2426					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
2427					if sample in self.unknowns:
2428						Nf += len(X) - 1
2429					else:
2430						Nf += len(X)
2431			if samples in ['anchors', 'all samples']:
2432				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
2433			r = (chisq / Nf)**.5 if Nf > 0 else 0
2434
2435		else: # if key not in ['D47', 'D48']
2436			chisq, Nf = 0, 0
2437			for sample in mysamples :
2438				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2439				if len(X) > 1 :
2440					Nf += len(X) - 1
2441					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
2442			r = (chisq / Nf)**.5 if Nf > 0 else 0
2443
2444		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
2445		return r
2446
2447	def sample_average(self, samples, weights = 'equal', normalize = True):
2448		'''
2449		Weighted average Δ4x value of a group of samples, accounting for covariance.
2450
2451		Returns the weighed average Δ4x value and associated SE
2452		of a group of samples. Weights are equal by default. If `normalize` is
2453		true, `weights` will be rescaled so that their sum equals 1.
2454
2455		**Examples**
2456
2457		```python
2458		self.sample_average(['X','Y'], [1, 2])
2459		```
2460
2461		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
2462		where Δ4x(X) and Δ4x(Y) are the average Δ4x
2463		values of samples X and Y, respectively.
2464
2465		```python
2466		self.sample_average(['X','Y'], [1, -1], normalize = False)
2467		```
2468
2469		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
2470		'''
2471		if weights == 'equal':
2472			weights = [1/len(samples)] * len(samples)
2473
2474		if normalize:
2475			s = sum(weights)
2476			if s:
2477				weights = [w/s for w in weights]
2478
2479		try:
2480# 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
2481# 			C = self.standardization.covar[indices,:][:,indices]
2482			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
2483			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
2484			return correlated_sum(X, C, weights)
2485		except ValueError:
2486			return (0., 0.)
2487
2488
2489	def sample_D4x_covar(self, sample1, sample2 = None):
2490		'''
2491		Covariance between Δ4x values of samples
2492
2493		Returns the error covariance between the average Δ4x values of two
2494		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
2495		returns the Δ4x variance for that sample.
2496		'''
2497		if sample2 is None:
2498			sample2 = sample1
2499		if self.standardization_method == 'pooled':
2500			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
2501			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
2502			return self.standardization.covar[i, j]
2503		elif self.standardization_method == 'indep_sessions':
2504			if sample1 == sample2:
2505				return self.samples[sample1][f'SE_D{self._4x}']**2
2506			else:
2507				c = 0
2508				for session in self.sessions:
2509					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
2510					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
2511					if sdata1 and sdata2:
2512						a = self.sessions[session]['a']
2513						# !! TODO: CM below does not account for temporal changes in standardization parameters
2514						CM = self.sessions[session]['CM'][:3,:3]
2515						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
2516						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
2517						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
2518						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
2519						c += (
2520							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
2521							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
2522							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
2523							@ CM
2524							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
2525							) / a**2
2526				return float(c)
2527
2528	def sample_D4x_correl(self, sample1, sample2 = None):
2529		'''
2530		Correlation between Δ4x errors of samples
2531
2532		Returns the error correlation between the average Δ4x values of two samples.
2533		'''
2534		if sample2 is None or sample2 == sample1:
2535			return 1.
2536		return (
2537			self.sample_D4x_covar(sample1, sample2)
2538			/ self.unknowns[sample1][f'SE_D{self._4x}']
2539			/ self.unknowns[sample2][f'SE_D{self._4x}']
2540			)
2541
2542	def plot_single_session(self,
2543		session,
2544		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
2545		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
2546		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
2547		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
2548		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
2549		xylimits = 'free', # | 'constant'
2550		x_label = None,
2551		y_label = None,
2552		error_contour_interval = 'auto',
2553		fig = 'new',
2554		):
2555		'''
2556		Generate plot for a single session
2557		'''
2558		if x_label is None:
2559			x_label = f'δ$_{{{self._4x}}}$ (‰)'
2560		if y_label is None:
2561			y_label = f'Δ$_{{{self._4x}}}$ (‰)'
2562
2563		out = _SessionPlot()
2564		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
2565		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
2566		
2567		if fig == 'new':
2568			out.fig = ppl.figure(figsize = (6,6))
2569			ppl.subplots_adjust(.1,.1,.9,.9)
2570
2571		out.anchor_analyses, = ppl.plot(
2572			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
2573			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
2574			**kw_plot_anchors)
2575		out.unknown_analyses, = ppl.plot(
2576			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
2577			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
2578			**kw_plot_unknowns)
2579		out.anchor_avg = ppl.plot(
2580			np.array([ np.array([
2581				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
2582				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
2583				]) for sample in anchors]).T,
2584			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
2585			**kw_plot_anchor_avg)
2586		out.unknown_avg = ppl.plot(
2587			np.array([ np.array([
2588				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
2589				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
2590				]) for sample in unknowns]).T,
2591			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
2592			**kw_plot_unknown_avg)
2593		if xylimits == 'constant':
2594			x = [r[f'd{self._4x}'] for r in self]
2595			y = [r[f'D{self._4x}'] for r in self]
2596			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
2597			w, h = x2-x1, y2-y1
2598			x1 -= w/20
2599			x2 += w/20
2600			y1 -= h/20
2601			y2 += h/20
2602			ppl.axis([x1, x2, y1, y2])
2603		elif xylimits == 'free':
2604			x1, x2, y1, y2 = ppl.axis()
2605		else:
2606			x1, x2, y1, y2 = ppl.axis(xylimits)
2607				
2608		if error_contour_interval != 'none':
2609			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
2610			XI,YI = np.meshgrid(xi, yi)
2611			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
2612			if error_contour_interval == 'auto':
2613				rng = np.max(SI) - np.min(SI)
2614				if rng <= 0.01:
2615					cinterval = 0.001
2616				elif rng <= 0.03:
2617					cinterval = 0.004
2618				elif rng <= 0.1:
2619					cinterval = 0.01
2620				elif rng <= 0.3:
2621					cinterval = 0.03
2622				elif rng <= 1.:
2623					cinterval = 0.1
2624				else:
2625					cinterval = 0.5
2626			else:
2627				cinterval = error_contour_interval
2628
2629			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
2630			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
2631			out.clabel = ppl.clabel(out.contour)
2632
2633		ppl.xlabel(x_label)
2634		ppl.ylabel(y_label)
2635		ppl.title(session, weight = 'bold')
2636		ppl.grid(alpha = .2)
2637		out.ax = ppl.gca()		
2638
2639		return out
2640
2641	def plot_residuals(
2642		self,
2643		hist = False,
2644		binwidth = 2/3,
2645		dir = 'output',
2646		filename = None,
2647		highlight = [],
2648		colors = None,
2649		figsize = None,
2650		):
2651		'''
2652		Plot residuals of each analysis as a function of time (actually, as a function of
2653		the order of analyses in the `D4xdata` object)
2654
2655		+ `hist`: whether to add a histogram of residuals
2656		+ `histbins`: specify bin edges for the histogram
2657		+ `dir`: the directory in which to save the plot
2658		+ `highlight`: a list of samples to highlight
2659		+ `colors`: a dict of `{<sample>: <color>}` for all samples
2660		+ `figsize`: (width, height) of figure
2661		'''
2662		# Layout
2663		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
2664		if hist:
2665			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
2666			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
2667		else:
2668			ppl.subplots_adjust(.08,.05,.78,.8)
2669			ax1 = ppl.subplot(111)
2670		
2671		# Colors
2672		N = len(self.anchors)
2673		if colors is None:
2674			if len(highlight) > 0:
2675				Nh = len(highlight)
2676				if Nh == 1:
2677					colors = {highlight[0]: (0,0,0)}
2678				elif Nh == 3:
2679					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
2680				elif Nh == 4:
2681					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
2682				else:
2683					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
2684			else:
2685				if N == 3:
2686					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
2687				elif N == 4:
2688					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
2689				else:
2690					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}
2691
2692		ppl.sca(ax1)
2693		
2694		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)
2695
2696		session = self[0]['Session']
2697		x1 = 0
2698# 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
2699		x_sessions = {}
2700		one_or_more_singlets = False
2701		one_or_more_multiplets = False
2702		multiplets = set()
2703		for k,r in enumerate(self):
2704			if r['Session'] != session:
2705				x2 = k-1
2706				x_sessions[session] = (x1+x2)/2
2707				ppl.axvline(k - 0.5, color = 'k', lw = .5)
2708				session = r['Session']
2709				x1 = k
2710			singlet = len(self.samples[r['Sample']]['data']) == 1
2711			if not singlet:
2712				multiplets.add(r['Sample'])
2713			if r['Sample'] in self.unknowns:
2714				if singlet:
2715					one_or_more_singlets = True
2716				else:
2717					one_or_more_multiplets = True
2718			kw = dict(
2719				marker = 'x' if singlet else '+',
2720				ms = 4 if singlet else 5,
2721				ls = 'None',
2722				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
2723				mew = 1,
2724				alpha = 0.2 if singlet else 1,
2725				)
2726			if highlight and r['Sample'] not in highlight:
2727				kw['alpha'] = 0.2
2728			ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
2729		x2 = k
2730		x_sessions[session] = (x1+x2)/2
2731
2732		ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
2733		ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
2734		if not hist:
2735			ppl.text(len(self), self.repeatability['r_D47']*1000, f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
2736			ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f"   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center')
2737
2738		xmin, xmax, ymin, ymax = ppl.axis()
2739		for s in x_sessions:
2740			ppl.text(
2741				x_sessions[s],
2742				ymax +1,
2743				s,
2744				va = 'bottom',
2745				**(
2746					dict(ha = 'center')
2747					if len(self.sessions[s]['data']) > (0.15 * len(self))
2748					else dict(ha = 'left', rotation = 45)
2749					)
2750				)
2751
2752		if hist:
2753			ppl.sca(ax2)
2754
2755		for s in colors:
2756			kw['marker'] = '+'
2757			kw['ms'] = 5
2758			kw['mec'] = colors[s]
2759			kw['label'] = s
2760			kw['alpha'] = 1
2761			ppl.plot([], [], **kw)
2762
2763		kw['mec'] = (0,0,0)
2764
2765		if one_or_more_singlets:
2766			kw['marker'] = 'x'
2767			kw['ms'] = 4
2768			kw['alpha'] = .2
2769			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
2770			ppl.plot([], [], **kw)
2771
2772		if one_or_more_multiplets:
2773			kw['marker'] = '+'
2774			kw['ms'] = 4
2775			kw['alpha'] = 1
2776			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
2777			ppl.plot([], [], **kw)
2778
2779		if hist:
2780			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
2781		else:
2782			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
2783		leg.set_zorder(-1000)
2784
2785		ppl.sca(ax1)
2786
2787		ppl.ylabel('Δ$_{47}$ residuals (ppm)')
2788		ppl.xticks([])
2789		ppl.axis([-1, len(self), None, None])
2790
2791		if hist:
2792			ppl.sca(ax2)
2793			X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self if r['Sample'] in multiplets]
2794			ppl.hist(
2795				X,
2796				orientation = 'horizontal',
2797				histtype = 'stepfilled',
2798				ec = [.4]*3,
2799				fc = [.25]*3,
2800				alpha = .25,
2801				bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
2802				)
2803			ppl.axis([None, None, ymin, ymax])
2804			ppl.text(0, 0,
2805				f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm\n   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
2806				size = 8,
2807				alpha = 1,
2808				va = 'center',
2809				ha = 'left',
2810				)
2811
2812			ppl.xticks([])
2813			ppl.yticks([])
2814# 			ax2.spines['left'].set_visible(False)
2815			ax2.spines['right'].set_visible(False)
2816			ax2.spines['top'].set_visible(False)
2817			ax2.spines['bottom'].set_visible(False)
2818
2819
2820		if not os.path.exists(dir):
2821			os.makedirs(dir)
2822		if filename is None:
2823			return fig
2824		elif filename == '':
2825			filename = f'D{self._4x}_residuals.pdf'
2826		ppl.savefig(f'{dir}/{filename}')
2827		ppl.close(fig)
2828				
2829
2830	def simulate(self, *args, **kwargs):
2831		'''
2832		Legacy function with warning message pointing to `virtual_data()`
2833		'''
2834		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
2835
2836	def plot_distribution_of_analyses(
2837		self,
2838		dir = 'output',
2839		filename = None,
2840		vs_time = False,
2841		figsize = (6,4),
2842		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
2843		output = None,
2844		):
2845		'''
2846		Plot temporal distribution of all analyses in the data set.
2847		
2848		**Parameters**
2849
2850		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
2851		'''
2852
2853		asamples = [s for s in self.anchors]
2854		usamples = [s for s in self.unknowns]
2855		if output is None or output == 'fig':
2856			fig = ppl.figure(figsize = figsize)
2857			ppl.subplots_adjust(*subplots_adjust)
2858		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
2859		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
2860		Xmax += (Xmax-Xmin)/40
2861		Xmin -= (Xmax-Xmin)/41
2862		for k, s in enumerate(asamples + usamples):
2863			if vs_time:
2864				X = [r['TimeTag'] for r in self if r['Sample'] == s]
2865			else:
2866				X = [x for x,r in enumerate(self) if r['Sample'] == s]
2867			Y = [-k for x in X]
2868			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
2869			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
2870			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
2871		ppl.axis([Xmin, Xmax, -k-1, 1])
2872		ppl.xlabel('\ntime')
2873		ppl.gca().annotate('',
2874			xy = (0.6, -0.02),
2875			xycoords = 'axes fraction',
2876			xytext = (.4, -0.02), 
2877            arrowprops = dict(arrowstyle = "->", color = 'k'),
2878            )
2879			
2880
2881		x2 = -1
2882		for session in self.sessions:
2883			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
2884			if vs_time:
2885				ppl.axvline(x1, color = 'k', lw = .75)
2886			if x2 > -1:
2887				if not vs_time:
2888					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
2889			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
2890# 			from xlrd import xldate_as_datetime
2891# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
2892			if vs_time:
2893				ppl.axvline(x2, color = 'k', lw = .75)
2894				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
2895			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
2896
2897		ppl.xticks([])
2898		ppl.yticks([])
2899
2900		if output is None:
2901			if not os.path.exists(dir):
2902				os.makedirs(dir)
2903			if filename == None:
2904				filename = f'D{self._4x}_distribution_of_analyses.pdf'
2905			ppl.savefig(f'{dir}/{filename}')
2906			ppl.close(fig)
2907		elif output == 'ax':
2908			return ppl.gca()
2909		elif output == 'fig':
2910			return fig
>>>>>>> master

Store and process data for a large set of Δ47 and/or Δ48 analyses, usually comprising more than one analytical session.

D4xdata(l=[], mass='47', logfile='', session='mySession', verbose=False)
<<<<<<< HEAD
970	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
971		'''
972		**Parameters**
973
974		+ `l`: a list of dictionaries, with each dictionary including at least the keys
975		`Sample`, `d45`, `d46`, and `d47` or `d48`.
976		+ `mass`: `'47'` or `'48'`
977		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
978		+ `session`: define session name for analyses without a `Session` key
979		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.
980
981		Returns a `D4xdata` object derived from `list`.
982		'''
983		self._4x = mass
984		self.verbose = verbose
985		self.prefix = 'D4xdata'
986		self.logfile = logfile
987		list.__init__(self, l)
988		self.Nf = None
989		self.repeatability = {}
990		self.refresh(session = session)
=======
            
972	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
973		'''
974		**Parameters**
975
976		+ `l`: a list of dictionaries, with each dictionary including at least the keys
977		`Sample`, `d45`, `d46`, and `d47` or `d48`.
978		+ `mass`: `'47'` or `'48'`
979		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
980		+ `session`: define session name for analyses without a `Session` key
981		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.
982
983		Returns a `D4xdata` object derived from `list`.
984		'''
985		self._4x = mass
986		self.verbose = verbose
987		self.prefix = 'D4xdata'
988		self.logfile = logfile
989		list.__init__(self, l)
990		self.Nf = None
991		self.repeatability = {}
992		self.refresh(session = session)
>>>>>>> master

Parameters

  • l: a list of dictionaries, with each dictionary including at least the keys Sample, d45, d46, and d47 or d48.
  • mass: '47' or '48'
  • logfile: if specified, write detailed logs to this file path when calling D4xdata methods.
  • session: define session name for analyses without a Session key
  • verbose: if True, print out detailed logs when calling D4xdata methods.

Returns a D4xdata object derived from list.

R13_VPDB = 0.01118

Absolute (13C/12C) ratio of VPDB. By default equal to 0.01118 (Chang & Li, 1990)

R18_VSMOW = 0.0020052

Absolute (18O/16C) ratio of VSMOW. By default equal to 0.0020052 (Baertschi, 1976)

LAMBDA_17 = 0.528

Mass-dependent exponent for triple oxygen isotopes. By default equal to 0.528 (Barkan & Luz, 2005)

R17_VSMOW = 0.00038475

Absolute (17O/16C) ratio of VSMOW. By default equal to 0.00038475 (Assonov & Brenninkmeijer, 2003, rescaled to R13_VPDB)

R18_VPDB = 0.0020672007840000003

Absolute (18O/16C) ratio of VPDB. By definition equal to R18_VSMOW * 1.03092.

R17_VPDB = 0.0003909861828790272

Absolute (17O/16C) ratio of VPDB. By definition equal to R17_VSMOW * 1.03092 ** LAMBDA_17.

LEVENE_REF_SAMPLE = 'ETH-3'

After the Δ4x standardization step, each sample is tested to assess whether the Δ4x variance within all analyses for that sample differs significantly from that observed for a given reference sample (using Levene's test, which yields a p-value corresponding to the null hypothesis that the underlying variances are equal).

LEVENE_REF_SAMPLE (by default equal to 'ETH-3') specifies which sample should be used as a reference for this test.

ALPHA_18O_ACID_REACTION = 1.008129

Specifies the 18O/16O fractionation factor generally applicable to acid reactions in the dataset. Currently used by D4xdata.wg(), D4xdata.standardize_d13C, and D4xdata.standardize_d18O.

By default equal to 1.008129 (calcite reacted at 90 °C, Kim et al., 2007).

Nominal_d13C_VPDB = {'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71}

Nominal δ13CVPDB values assigned to carbonate standards, used by D4xdata.standardize_d13C().

By default equal to {'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71} after Bernasconi et al. (2018).

Nominal_d18O_VPDB = {'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78}

Nominal δ18OVPDB values assigned to carbonate standards, used by D4xdata.standardize_d18O().

By default equal to {'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78} after Bernasconi et al. (2018).

d13C_STANDARDIZATION_METHOD = '2pt'

Method by which to standardize δ13C values:

  • none: do not apply any δ13C standardization.
  • '1pt': within each session, offset all initial δ13C values so as to minimize the difference between final δ13CVPDB values and Nominal_d13C_VPDB (averaged over all analyses for which Nominal_d13C_VPDB is defined).
  • '2pt': within each session, apply a affine trasformation to all δ13C values so as to minimize the difference between final δ13CVPDB values and Nominal_d13C_VPDB (averaged over all analyses for which Nominal_d13C_VPDB is defined).
d18O_STANDARDIZATION_METHOD = '2pt'

Method by which to standardize δ18O values:

  • none: do not apply any δ18O standardization.
  • '1pt': within each session, offset all initial δ18O values so as to minimize the difference between final δ18OVPDB values and Nominal_d18O_VPDB (averaged over all analyses for which Nominal_d18O_VPDB is defined).
  • '2pt': within each session, apply a affine trasformation to all δ18O values so as to minimize the difference between final δ18OVPDB values and Nominal_d18O_VPDB (averaged over all analyses for which Nominal_d18O_VPDB is defined).
def make_verbal(oldfun):
<<<<<<< HEAD
 993	def make_verbal(oldfun):
 994		'''
 995		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
 996		'''
 997		@wraps(oldfun)
 998		def newfun(*args, verbose = '', **kwargs):
 999			myself = args[0]
1000			oldprefix = myself.prefix
1001			myself.prefix = oldfun.__name__
1002			if verbose != '':
1003				oldverbose = myself.verbose
1004				myself.verbose = verbose
1005			out = oldfun(*args, **kwargs)
1006			myself.prefix = oldprefix
1007			if verbose != '':
1008				myself.verbose = oldverbose
1009			return out
1010		return newfun
=======
            
 995	def make_verbal(oldfun):
 996		'''
 997		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
 998		'''
 999		@wraps(oldfun)
1000		def newfun(*args, verbose = '', **kwargs):
1001			myself = args[0]
1002			oldprefix = myself.prefix
1003			myself.prefix = oldfun.__name__
1004			if verbose != '':
1005				oldverbose = myself.verbose
1006				myself.verbose = verbose
1007			out = oldfun(*args, **kwargs)
1008			myself.prefix = oldprefix
1009			if verbose != '':
1010				myself.verbose = oldverbose
1011			return out
1012		return newfun
>>>>>>> master

Decorator: allow temporarily changing self.prefix and overriding self.verbose.

def msg(self, txt):
<<<<<<< HEAD
1013	def msg(self, txt):
1014		'''
1015		Log a message to `self.logfile`, and print it out if `verbose = True`
1016		'''
1017		self.log(txt)
1018		if self.verbose:
1019			print(f'{f"[{self.prefix}]":<16} {txt}')
=======
            
1015	def msg(self, txt):
1016		'''
1017		Log a message to `self.logfile`, and print it out if `verbose = True`
1018		'''
1019		self.log(txt)
1020		if self.verbose:
1021			print(f'{f"[{self.prefix}]":<16} {txt}')
>>>>>>> master

Log a message to self.logfile, and print it out if verbose = True

def vmsg(self, txt):
<<<<<<< HEAD
1022	def vmsg(self, txt):
1023		'''
1024		Log a message to `self.logfile` and print it out
1025		'''
1026		self.log(txt)
1027		print(txt)
=======
            
1024	def vmsg(self, txt):
1025		'''
1026		Log a message to `self.logfile` and print it out
1027		'''
1028		self.log(txt)
1029		print(txt)
>>>>>>> master

Log a message to self.logfile and print it out

def log(self, *txts):
<<<<<<< HEAD
1030	def log(self, *txts):
1031		'''
1032		Log a message to `self.logfile`
1033		'''
1034		if self.logfile:
1035			with open(self.logfile, 'a') as fid:
1036				for txt in txts:
1037					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
=======
            
1032	def log(self, *txts):
1033		'''
1034		Log a message to `self.logfile`
1035		'''
1036		if self.logfile:
1037			with open(self.logfile, 'a') as fid:
1038				for txt in txts:
1039					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
>>>>>>> master

Log a message to self.logfile

def refresh(self, session='mySession'):
<<<<<<< HEAD
1040	def refresh(self, session = 'mySession'):
1041		'''
1042		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
1043		'''
1044		self.fill_in_missing_info(session = session)
1045		self.refresh_sessions()
1046		self.refresh_samples()
=======
            
1042	def refresh(self, session = 'mySession'):
1043		'''
1044		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
1045		'''
1046		self.fill_in_missing_info(session = session)
1047		self.refresh_sessions()
1048		self.refresh_samples()
>>>>>>> master

Update self.sessions, self.samples, self.anchors, and self.unknowns.

def refresh_sessions(self):
<<<<<<< HEAD
1049	def refresh_sessions(self):
1050		'''
1051		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
1052		to `False` for all sessions.
1053		'''
1054		self.sessions = {
1055			s: {'data': [r for r in self if r['Session'] == s]}
1056			for s in sorted({r['Session'] for r in self})
1057			}
1058		for s in self.sessions:
1059			self.sessions[s]['scrambling_drift'] = False
1060			self.sessions[s]['slope_drift'] = False
1061			self.sessions[s]['wg_drift'] = False
1062			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
1063			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
=======
            
1051	def refresh_sessions(self):
1052		'''
1053		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
1054		to `False` for all sessions.
1055		'''
1056		self.sessions = {
1057			s: {'data': [r for r in self if r['Session'] == s]}
1058			for s in sorted({r['Session'] for r in self})
1059			}
1060		for s in self.sessions:
1061			self.sessions[s]['scrambling_drift'] = False
1062			self.sessions[s]['slope_drift'] = False
1063			self.sessions[s]['wg_drift'] = False
1064			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
1065			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
>>>>>>> master

Update self.sessions and set scrambling_drift, slope_drift, and wg_drift to False for all sessions.

def refresh_samples(self):
<<<<<<< HEAD
1066	def refresh_samples(self):
1067		'''
1068		Define `self.samples`, `self.anchors`, and `self.unknowns`.
1069		'''
1070		self.samples = {
1071			s: {'data': [r for r in self if r['Sample'] == s]}
1072			for s in sorted({r['Sample'] for r in self})
1073			}
1074		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
1075		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
=======
            
1068	def refresh_samples(self):
1069		'''
1070		Define `self.samples`, `self.anchors`, and `self.unknowns`.
1071		'''
1072		self.samples = {
1073			s: {'data': [r for r in self if r['Sample'] == s]}
1074			for s in sorted({r['Sample'] for r in self})
1075			}
1076		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
1077		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
>>>>>>> master

Define self.samples, self.anchors, and self.unknowns.

def read(self, filename, sep='', session=''): <<<<<<< HEAD
1078	def read(self, filename, sep = '', session = ''):
1079		'''
1080		Read file in csv format to load data into a `D47data` object.
1081
1082		In the csv file, spaces before and after field separators (`','` by default)
1083		are optional. Each line corresponds to a single analysis.
1084
1085		The required fields are:
1086
1087		+ `UID`: a unique identifier
1088		+ `Session`: an identifier for the analytical session
1089		+ `Sample`: a sample identifier
1090		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
1091
1092		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
1093		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
1094		and `d49` are optional, and set to NaN by default.
1095
1096		**Parameters**
1097
1098		+ `fileneme`: the path of the file to read
1099		+ `sep`: csv separator delimiting the fields
1100		+ `session`: set `Session` field to this string for all analyses
1101		'''
1102		with open(filename) as fid:
1103			self.input(fid.read(), sep = sep, session = session)
=======

                

    
1080	def read(self, filename, sep = '', session = ''):
1081		'''
1082		Read file in csv format to load data into a `D47data` object.
1083
1084		In the csv file, spaces before and after field separators (`','` by default)
1085		are optional. Each line corresponds to a single analysis.
1086
1087		The required fields are:
1088
1089		+ `UID`: a unique identifier
1090		+ `Session`: an identifier for the analytical session
1091		+ `Sample`: a sample identifier
1092		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
1093
1094		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
1095		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
1096		and `d49` are optional, and set to NaN by default.
1097
1098		**Parameters**
1099
1100		+ `fileneme`: the path of the file to read
1101		+ `sep`: csv separator delimiting the fields
1102		+ `session`: set `Session` field to this string for all analyses
1103		'''
1104		with open(filename) as fid:
1105			self.input(fid.read(), sep = sep, session = session)
>>>>>>> master

Read file in csv format to load data into a D47data object.

In the csv file, spaces before and after field separators (',' by default) are optional. Each line corresponds to a single analysis.

The required fields are:

  • UID: a unique identifier
  • Session: an identifier for the analytical session
  • Sample: a sample identifier
  • d45, d46, and at least one of d47 or d48: the working-gas delta values

Independently known oxygen-17 anomalies may be provided as D17O (in ‰ relative to VSMOW, λ = self.LAMBDA_17), and are otherwise assumed to be zero. Working-gas deltas d47, d48 and d49 are optional, and set to NaN by default.

Parameters

  • fileneme: the path of the file to read
  • sep: csv separator delimiting the fields
  • session: set Session field to this string for all analyses
def input(self, txt, sep='', session=''): <<<<<<< HEAD
1106	def input(self, txt, sep = '', session = ''):
1107		'''
1108		Read `txt` string in csv format to load analysis data into a `D47data` object.
1109
1110		In the csv string, spaces before and after field separators (`','` by default)
1111		are optional. Each line corresponds to a single analysis.
1112
1113		The required fields are:
1114
1115		+ `UID`: a unique identifier
1116		+ `Session`: an identifier for the analytical session
1117		+ `Sample`: a sample identifier
1118		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
1119
1120		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
1121		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
1122		and `d49` are optional, and set to NaN by default.
1123
1124		**Parameters**
1125
1126		+ `txt`: the csv string to read
1127		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
1128		whichever appers most often in `txt`.
1129		+ `session`: set `Session` field to this string for all analyses
1130		'''
1131		if sep == '':
1132			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
1133		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
1134		data = [{k: v if k in ['UID', 'Session', 'Sample'] else smart_type(v) for k,v in zip(txt[0], l) if v != ''} for l in txt[1:]]
1135
1136		if session != '':
1137			for r in data:
1138				r['Session'] = session
1139
1140		self += data
1141		self.refresh()
=======

                

    
1108	def input(self, txt, sep = '', session = ''):
1109		'''
1110		Read `txt` string in csv format to load analysis data into a `D47data` object.
1111
1112		In the csv string, spaces before and after field separators (`','` by default)
1113		are optional. Each line corresponds to a single analysis.
1114
1115		The required fields are:
1116
1117		+ `UID`: a unique identifier
1118		+ `Session`: an identifier for the analytical session
1119		+ `Sample`: a sample identifier
1120		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
1121
1122		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
1123		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
1124		and `d49` are optional, and set to NaN by default.
1125
1126		**Parameters**
1127
1128		+ `txt`: the csv string to read
1129		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
1130		whichever appers most often in `txt`.
1131		+ `session`: set `Session` field to this string for all analyses
1132		'''
1133		if sep == '':
1134			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
1135		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
1136		data = [{k: v if k in ['UID', 'Session', 'Sample'] else smart_type(v) for k,v in zip(txt[0], l) if v != ''} for l in txt[1:]]
1137
1138		if session != '':
1139			for r in data:
1140				r['Session'] = session
1141
1142		self += data
1143		self.refresh()
>>>>>>> master

Read txt string in csv format to load analysis data into a D47data object.

In the csv string, spaces before and after field separators (',' by default) are optional. Each line corresponds to a single analysis.

The required fields are:

  • UID: a unique identifier
  • Session: an identifier for the analytical session
  • Sample: a sample identifier
  • d45, d46, and at least one of d47 or d48: the working-gas delta values

Independently known oxygen-17 anomalies may be provided as D17O (in ‰ relative to VSMOW, λ = self.LAMBDA_17), and are otherwise assumed to be zero. Working-gas deltas d47, d48 and d49 are optional, and set to NaN by default.

Parameters

  • txt: the csv string to read
  • sep: csv separator delimiting the fields. By default, use ,, ;, or , whichever appers most often in txt.
  • session: set Session field to this string for all analyses
@make_verbal
def wg(self, samples=None, a18_acid=None):
<<<<<<< HEAD
1144	@make_verbal
1145	def wg(self, samples = None, a18_acid = None):
1146		'''
1147		Compute bulk composition of the working gas for each session based on
1148		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
1149		`self.Nominal_d18O_VPDB`.
1150		'''
1151
1152		self.msg('Computing WG composition:')
1153
1154		if a18_acid is None:
1155			a18_acid = self.ALPHA_18O_ACID_REACTION
1156		if samples is None:
1157			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
1158
1159		assert a18_acid, f'Acid fractionation factor should not be zero.'
1160
1161		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
1162		R45R46_standards = {}
1163		for sample in samples:
1164			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
1165			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
1166			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
1167			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
1168			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
1169
1170			C12_s = 1 / (1 + R13_s)
1171			C13_s = R13_s / (1 + R13_s)
1172			C16_s = 1 / (1 + R17_s + R18_s)
1173			C17_s = R17_s / (1 + R17_s + R18_s)
1174			C18_s = R18_s / (1 + R17_s + R18_s)
1175
1176			C626_s = C12_s * C16_s ** 2
1177			C627_s = 2 * C12_s * C16_s * C17_s
1178			C628_s = 2 * C12_s * C16_s * C18_s
1179			C636_s = C13_s * C16_s ** 2
1180			C637_s = 2 * C13_s * C16_s * C17_s
1181			C727_s = C12_s * C17_s ** 2
1182
1183			R45_s = (C627_s + C636_s) / C626_s
1184			R46_s = (C628_s + C637_s + C727_s) / C626_s
1185			R45R46_standards[sample] = (R45_s, R46_s)
1186		
1187		for s in self.sessions:
1188			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
1189			assert db, f'No sample from {samples} found in session "{s}".'
1190# 			dbsamples = sorted({r['Sample'] for r in db})
1191
1192			X = [r['d45'] for r in db]
1193			Y = [R45R46_standards[r['Sample']][0] for r in db]
1194			x1, x2 = np.min(X), np.max(X)
1195
1196			if x1 < x2:
1197				wgcoord = x1/(x1-x2)
1198			else:
1199				wgcoord = 999
1200
1201			if wgcoord < -.5 or wgcoord > 1.5:
1202				# unreasonable to extrapolate to d45 = 0
1203				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
1204			else :
1205				# d45 = 0 is reasonably well bracketed
1206				R45_wg = np.polyfit(X, Y, 1)[1]
1207
1208			X = [r['d46'] for r in db]
1209			Y = [R45R46_standards[r['Sample']][1] for r in db]
1210			x1, x2 = np.min(X), np.max(X)
1211
1212			if x1 < x2:
1213				wgcoord = x1/(x1-x2)
1214			else:
1215				wgcoord = 999
1216
1217			if wgcoord < -.5 or wgcoord > 1.5:
1218				# unreasonable to extrapolate to d46 = 0
1219				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
1220			else :
1221				# d46 = 0 is reasonably well bracketed
1222				R46_wg = np.polyfit(X, Y, 1)[1]
1223
1224			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
1225
1226			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
1227
1228			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
1229			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
1230			for r in self.sessions[s]['data']:
1231				r['d13Cwg_VPDB'] = d13Cwg_VPDB
1232				r['d18Owg_VSMOW'] = d18Owg_VSMOW
=======
            
1146	@make_verbal
1147	def wg(self, samples = None, a18_acid = None):
1148		'''
1149		Compute bulk composition of the working gas for each session based on
1150		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
1151		`self.Nominal_d18O_VPDB`.
1152		'''
1153
1154		self.msg('Computing WG composition:')
1155
1156		if a18_acid is None:
1157			a18_acid = self.ALPHA_18O_ACID_REACTION
1158		if samples is None:
1159			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
1160
1161		assert a18_acid, f'Acid fractionation factor should not be zero.'
1162
1163		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
1164		R45R46_standards = {}
1165		for sample in samples:
1166			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
1167			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
1168			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
1169			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
1170			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
1171
1172			C12_s = 1 / (1 + R13_s)
1173			C13_s = R13_s / (1 + R13_s)
1174			C16_s = 1 / (1 + R17_s + R18_s)
1175			C17_s = R17_s / (1 + R17_s + R18_s)
1176			C18_s = R18_s / (1 + R17_s + R18_s)
1177
1178			C626_s = C12_s * C16_s ** 2
1179			C627_s = 2 * C12_s * C16_s * C17_s
1180			C628_s = 2 * C12_s * C16_s * C18_s
1181			C636_s = C13_s * C16_s ** 2
1182			C637_s = 2 * C13_s * C16_s * C17_s
1183			C727_s = C12_s * C17_s ** 2
1184
1185			R45_s = (C627_s + C636_s) / C626_s
1186			R46_s = (C628_s + C637_s + C727_s) / C626_s
1187			R45R46_standards[sample] = (R45_s, R46_s)
1188		
1189		for s in self.sessions:
1190			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
1191			assert db, f'No sample from {samples} found in session "{s}".'
1192# 			dbsamples = sorted({r['Sample'] for r in db})
1193
1194			X = [r['d45'] for r in db]
1195			Y = [R45R46_standards[r['Sample']][0] for r in db]
1196			x1, x2 = np.min(X), np.max(X)
1197
1198			if x1 < x2:
1199				wgcoord = x1/(x1-x2)
1200			else:
1201				wgcoord = 999
1202
1203			if wgcoord < -.5 or wgcoord > 1.5:
1204				# unreasonable to extrapolate to d45 = 0
1205				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
1206			else :
1207				# d45 = 0 is reasonably well bracketed
1208				R45_wg = np.polyfit(X, Y, 1)[1]
1209
1210			X = [r['d46'] for r in db]
1211			Y = [R45R46_standards[r['Sample']][1] for r in db]
1212			x1, x2 = np.min(X), np.max(X)
1213
1214			if x1 < x2:
1215				wgcoord = x1/(x1-x2)
1216			else:
1217				wgcoord = 999
1218
1219			if wgcoord < -.5 or wgcoord > 1.5:
1220				# unreasonable to extrapolate to d46 = 0
1221				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
1222			else :
1223				# d46 = 0 is reasonably well bracketed
1224				R46_wg = np.polyfit(X, Y, 1)[1]
1225
1226			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
1227
1228			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
1229
1230			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
1231			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
1232			for r in self.sessions[s]['data']:
1233				r['d13Cwg_VPDB'] = d13Cwg_VPDB
1234				r['d18Owg_VSMOW'] = d18Owg_VSMOW
>>>>>>> master

Compute bulk composition of the working gas for each session based on the carbonate standards defined in both self.Nominal_d13C_VPDB and self.Nominal_d18O_VPDB.

def compute_bulk_delta(self, R45, R46, D17O=0): <<<<<<< HEAD
1235	def compute_bulk_delta(self, R45, R46, D17O = 0):
1236		'''
1237		Compute δ13C_VPDB and δ18O_VSMOW,
1238		by solving the generalized form of equation (17) from
1239		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
1240		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
1241		solving the corresponding second-order Taylor polynomial.
1242		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
1243		'''
1244
1245		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
1246
1247		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
1248		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
1249		C = 2 * self.R18_VSMOW
1250		D = -R46
1251
1252		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
1253		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
1254		cc = A + B + C + D
1255
1256		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
1257
1258		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
1259		R17 = K * R18 ** self.LAMBDA_17
1260		R13 = R45 - 2 * R17
1261
1262		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
1263
1264		return d13C_VPDB, d18O_VSMOW
=======

                

    
1237	def compute_bulk_delta(self, R45, R46, D17O = 0):
1238		'''
1239		Compute δ13C_VPDB and δ18O_VSMOW,
1240		by solving the generalized form of equation (17) from
1241		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
1242		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
1243		solving the corresponding second-order Taylor polynomial.
1244		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
1245		'''
1246
1247		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
1248
1249		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
1250		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
1251		C = 2 * self.R18_VSMOW
1252		D = -R46
1253
1254		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
1255		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
1256		cc = A + B + C + D
1257
1258		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
1259
1260		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
1261		R17 = K * R18 ** self.LAMBDA_17
1262		R13 = R45 - 2 * R17
1263
1264		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
1265
1266		return d13C_VPDB, d18O_VSMOW
>>>>>>> master

Compute δ13CVPDB and δ18OVSMOW, by solving the generalized form of equation (17) from Brand et al. (2010), assuming that δ18OVSMOW is not too big (0 ± 50 ‰) and solving the corresponding second-order Taylor polynomial. (Appendix A of Daëron et al., 2016)

@make_verbal
<<<<<<< HEAD def crunch(self, verbose=''): ======= def crunch(self, verbose=''): >>>>>>> master
<<<<<<< HEAD
1267	@make_verbal
1268	def crunch(self, verbose = ''):
1269		'''
1270		Compute bulk composition and raw clumped isotope anomalies for all analyses.
1271		'''
1272		for r in self:
1273			self.compute_bulk_and_clumping_deltas(r)
1274		self.standardize_d13C()
1275		self.standardize_d18O()
1276		self.msg(f"Crunched {len(self)} analyses.")
=======
            
1269	@make_verbal
1270	def crunch(self, verbose = ''):
1271		'''
1272		Compute bulk composition and raw clumped isotope anomalies for all analyses.
1273		'''
1274		for r in self:
1275			self.compute_bulk_and_clumping_deltas(r)
1276		self.standardize_d13C()
1277		self.standardize_d18O()
1278		self.msg(f"Crunched {len(self)} analyses.")
>>>>>>> master

Compute bulk composition and raw clumped isotope anomalies for all analyses.

def fill_in_missing_info(self, session='mySession'):
<<<<<<< HEAD
1279	def fill_in_missing_info(self, session = 'mySession'):
1280		'''
1281		Fill in optional fields with default values
1282		'''
1283		for i,r in enumerate(self):
1284			if 'D17O' not in r:
1285				r['D17O'] = 0.
1286			if 'UID' not in r:
1287				r['UID'] = f'{i+1}'
1288			if 'Session' not in r:
1289				r['Session'] = session
1290			for k in ['d47', 'd48', 'd49']:
1291				if k not in r:
1292					r[k] = np.nan
=======
            
1281	def fill_in_missing_info(self, session = 'mySession'):
1282		'''
1283		Fill in optional fields with default values
1284		'''
1285		for i,r in enumerate(self):
1286			if 'D17O' not in r:
1287				r['D17O'] = 0.
1288			if 'UID' not in r:
1289				r['UID'] = f'{i+1}'
1290			if 'Session' not in r:
1291				r['Session'] = session
1292			for k in ['d47', 'd48', 'd49']:
1293				if k not in r:
1294					r[k] = np.nan
>>>>>>> master

Fill in optional fields with default values

def standardize_d13C(self):
<<<<<<< HEAD
1295	def standardize_d13C(self):
1296		'''
1297		Perform δ13C standadization within each session `s` according to
1298		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
1299		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
1300		may be redefined abitrarily at a later stage.
1301		'''
1302		for s in self.sessions:
1303			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
1304				XY = [(r['d13C_VPDB'], self.Nominal_d13C_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d13C_VPDB]
1305				X,Y = zip(*XY)
1306				if self.sessions[s]['d13C_standardization_method'] == '1pt':
1307					offset = np.mean(Y) - np.mean(X)
1308					for r in self.sessions[s]['data']:
1309						r['d13C_VPDB'] += offset				
1310				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
1311					a,b = np.polyfit(X,Y,1)
1312					for r in self.sessions[s]['data']:
1313						r['d13C_VPDB'] = a * r['d13C_VPDB'] + b
=======
            
1297	def standardize_d13C(self):
1298		'''
1299		Perform δ13C standadization within each session `s` according to
1300		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
1301		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
1302		may be redefined abitrarily at a later stage.
1303		'''
1304		for s in self.sessions:
1305			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
1306				XY = [(r['d13C_VPDB'], self.Nominal_d13C_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d13C_VPDB]
1307				X,Y = zip(*XY)
1308				if self.sessions[s]['d13C_standardization_method'] == '1pt':
1309					offset = np.mean(Y) - np.mean(X)
1310					for r in self.sessions[s]['data']:
1311						r['d13C_VPDB'] += offset				
1312				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
1313					a,b = np.polyfit(X,Y,1)
1314					for r in self.sessions[s]['data']:
1315						r['d13C_VPDB'] = a * r['d13C_VPDB'] + b
>>>>>>> master

Perform δ13C standadization within each session s according to self.sessions[s]['d13C_standardization_method'], which is defined by default by D47data.refresh_sessions()as equal to self.d13C_STANDARDIZATION_METHOD, but may be redefined abitrarily at a later stage.

def standardize_d18O(self):
<<<<<<< HEAD
1315	def standardize_d18O(self):
1316		'''
1317		Perform δ18O standadization within each session `s` according to
1318		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
1319		which is defined by default by `D47data.refresh_sessions()`as equal to
1320		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
1321		'''
1322		for s in self.sessions:
1323			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
1324				XY = [(r['d18O_VSMOW'], self.Nominal_d18O_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d18O_VPDB]
1325				X,Y = zip(*XY)
1326				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
1327				if self.sessions[s]['d18O_standardization_method'] == '1pt':
1328					offset = np.mean(Y) - np.mean(X)
1329					for r in self.sessions[s]['data']:
1330						r['d18O_VSMOW'] += offset				
1331				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
1332					a,b = np.polyfit(X,Y,1)
1333					for r in self.sessions[s]['data']:
1334						r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b
=======
            
1317	def standardize_d18O(self):
1318		'''
1319		Perform δ18O standadization within each session `s` according to
1320		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
1321		which is defined by default by `D47data.refresh_sessions()`as equal to
1322		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
1323		'''
1324		for s in self.sessions:
1325			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
1326				XY = [(r['d18O_VSMOW'], self.Nominal_d18O_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d18O_VPDB]
1327				X,Y = zip(*XY)
1328				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
1329				if self.sessions[s]['d18O_standardization_method'] == '1pt':
1330					offset = np.mean(Y) - np.mean(X)
1331					for r in self.sessions[s]['data']:
1332						r['d18O_VSMOW'] += offset				
1333				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
1334					a,b = np.polyfit(X,Y,1)
1335					for r in self.sessions[s]['data']:
1336						r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b
>>>>>>> master

Perform δ18O standadization within each session s according to self.ALPHA_18O_ACID_REACTION and self.sessions[s]['d18O_standardization_method'], which is defined by default by D47data.refresh_sessions()as equal to self.d18O_STANDARDIZATION_METHOD, but may be redefined abitrarily at a later stage.

def compute_bulk_and_clumping_deltas(self, r):
<<<<<<< HEAD
1337	def compute_bulk_and_clumping_deltas(self, r):
1338		'''
1339		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
1340		'''
1341
1342		# Compute working gas R13, R18, and isobar ratios
1343		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
1344		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
1345		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
1346
1347		# Compute analyte isobar ratios
1348		R45 = (1 + r['d45'] / 1000) * R45_wg
1349		R46 = (1 + r['d46'] / 1000) * R46_wg
1350		R47 = (1 + r['d47'] / 1000) * R47_wg
1351		R48 = (1 + r['d48'] / 1000) * R48_wg
1352		R49 = (1 + r['d49'] / 1000) * R49_wg
1353
1354		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
1355		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
1356		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
1357
1358		# Compute stochastic isobar ratios of the analyte
1359		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
1360			R13, R18, D17O = r['D17O']
1361		)
1362
1363		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
1364		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
1365		if (R45 / R45stoch - 1) > 5e-8:
1366			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
1367		if (R46 / R46stoch - 1) > 5e-8:
1368			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
1369
1370		# Compute raw clumped isotope anomalies
1371		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
1372		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
1373		r['D49raw'] = 1000 * (R49 / R49stoch - 1)
=======
            
1339	def compute_bulk_and_clumping_deltas(self, r):
1340		'''
1341		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
1342		'''
1343
1344		# Compute working gas R13, R18, and isobar ratios
1345		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
1346		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
1347		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
1348
1349		# Compute analyte isobar ratios
1350		R45 = (1 + r['d45'] / 1000) * R45_wg
1351		R46 = (1 + r['d46'] / 1000) * R46_wg
1352		R47 = (1 + r['d47'] / 1000) * R47_wg
1353		R48 = (1 + r['d48'] / 1000) * R48_wg
1354		R49 = (1 + r['d49'] / 1000) * R49_wg
1355
1356		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
1357		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
1358		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
1359
1360		# Compute stochastic isobar ratios of the analyte
1361		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
1362			R13, R18, D17O = r['D17O']
1363		)
1364
1365		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
1366		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
1367		if (R45 / R45stoch - 1) > 5e-8:
1368			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
1369		if (R46 / R46stoch - 1) > 5e-8:
1370			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
1371
1372		# Compute raw clumped isotope anomalies
1373		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
1374		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
1375		r['D49raw'] = 1000 * (R49 / R49stoch - 1)
>>>>>>> master

Compute δ13CVPDB, δ18OVSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis r.

def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
<<<<<<< HEAD
1376	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
1377		'''
1378		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
1379		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
1380		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
1381		'''
1382
1383		# Compute R17
1384		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
1385
1386		# Compute isotope concentrations
1387		C12 = (1 + R13) ** -1
1388		C13 = C12 * R13
1389		C16 = (1 + R17 + R18) ** -1
1390		C17 = C16 * R17
1391		C18 = C16 * R18
1392
1393		# Compute stochastic isotopologue concentrations
1394		C626 = C16 * C12 * C16
1395		C627 = C16 * C12 * C17 * 2
1396		C628 = C16 * C12 * C18 * 2
1397		C636 = C16 * C13 * C16
1398		C637 = C16 * C13 * C17 * 2
1399		C638 = C16 * C13 * C18 * 2
1400		C727 = C17 * C12 * C17
1401		C728 = C17 * C12 * C18 * 2
1402		C737 = C17 * C13 * C17
1403		C738 = C17 * C13 * C18 * 2
1404		C828 = C18 * C12 * C18
1405		C838 = C18 * C13 * C18
1406
1407		# Compute stochastic isobar ratios
1408		R45 = (C636 + C627) / C626
1409		R46 = (C628 + C637 + C727) / C626
1410		R47 = (C638 + C728 + C737) / C626
1411		R48 = (C738 + C828) / C626
1412		R49 = C838 / C626
1413
1414		# Account for stochastic anomalies
1415		R47 *= 1 + D47 / 1000
1416		R48 *= 1 + D48 / 1000
1417		R49 *= 1 + D49 / 1000
1418
1419		# Return isobar ratios
1420		return R45, R46, R47, R48, R49
=======
            
1378	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
1379		'''
1380		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
1381		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
1382		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
1383		'''
1384
1385		# Compute R17
1386		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
1387
1388		# Compute isotope concentrations
1389		C12 = (1 + R13) ** -1
1390		C13 = C12 * R13
1391		C16 = (1 + R17 + R18) ** -1
1392		C17 = C16 * R17
1393		C18 = C16 * R18
1394
1395		# Compute stochastic isotopologue concentrations
1396		C626 = C16 * C12 * C16
1397		C627 = C16 * C12 * C17 * 2
1398		C628 = C16 * C12 * C18 * 2
1399		C636 = C16 * C13 * C16
1400		C637 = C16 * C13 * C17 * 2
1401		C638 = C16 * C13 * C18 * 2
1402		C727 = C17 * C12 * C17
1403		C728 = C17 * C12 * C18 * 2
1404		C737 = C17 * C13 * C17
1405		C738 = C17 * C13 * C18 * 2
1406		C828 = C18 * C12 * C18
1407		C838 = C18 * C13 * C18
1408
1409		# Compute stochastic isobar ratios
1410		R45 = (C636 + C627) / C626
1411		R46 = (C628 + C637 + C727) / C626
1412		R47 = (C638 + C728 + C737) / C626
1413		R48 = (C738 + C828) / C626
1414		R49 = C838 / C626
1415
1416		# Account for stochastic anomalies
1417		R47 *= 1 + D47 / 1000
1418		R48 *= 1 + D48 / 1000
1419		R49 *= 1 + D49 / 1000
1420
1421		# Return isobar ratios
1422		return R45, R46, R47, R48, R49
>>>>>>> master

Compute isobar ratios for a sample with isotopic ratios R13 and R18, optionally accounting for non-zero values of Δ17O (D17O) and clumped isotope anomalies (D47, D48, D49), all expressed in permil.

def split_samples(self, samples_to_split='all', grouping='by_session'):
<<<<<<< HEAD
1423	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
1424		'''
1425		Split unknown samples by UID (treat all analyses as different samples)
1426		or by session (treat analyses of a given sample in different sessions as
1427		different samples).
1428
1429		**Parameters**
1430
1431		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
1432		+ `grouping`: `by_uid` | `by_session`
1433		'''
1434		if samples_to_split == 'all':
1435			samples_to_split = [s for s in self.unknowns]
1436		gkeys = {'by_uid':'UID', 'by_session':'Session'}
1437		self.grouping = grouping.lower()
1438		if self.grouping in gkeys:
1439			gkey = gkeys[self.grouping]
1440		for r in self:
1441			if r['Sample'] in samples_to_split:
1442				r['Sample_original'] = r['Sample']
1443				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
1444			elif r['Sample'] in self.unknowns:
1445				r['Sample_original'] = r['Sample']
1446		self.refresh_samples()
=======
            
1425	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
1426		'''
1427		Split unknown samples by UID (treat all analyses as different samples)
1428		or by session (treat analyses of a given sample in different sessions as
1429		different samples).
1430
1431		**Parameters**
1432
1433		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
1434		+ `grouping`: `by_uid` | `by_session`
1435		'''
1436		if samples_to_split == 'all':
1437			samples_to_split = [s for s in self.unknowns]
1438		gkeys = {'by_uid':'UID', 'by_session':'Session'}
1439		self.grouping = grouping.lower()
1440		if self.grouping in gkeys:
1441			gkey = gkeys[self.grouping]
1442		for r in self:
1443			if r['Sample'] in samples_to_split:
1444				r['Sample_original'] = r['Sample']
1445				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
1446			elif r['Sample'] in self.unknowns:
1447				r['Sample_original'] = r['Sample']
1448		self.refresh_samples()
>>>>>>> master

Split unknown samples by UID (treat all analyses as different samples) or by session (treat analyses of a given sample in different sessions as different samples).

Parameters

  • samples_to_split: a list of samples to split, e.g., ['IAEA-C1', 'IAEA-C2']
  • grouping: by_uid | by_session
def unsplit_samples(self, tables=False):
<<<<<<< HEAD
1449	def unsplit_samples(self, tables = False):
1450		'''
1451		Reverse the effects of `D47data.split_samples()`.
1452		
1453		This should only be used after `D4xdata.standardize()` with `method='pooled'`.
1454		
1455		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
1456		probably use `D4xdata.combine_samples()` instead to reverse the effects of
1457		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
1458		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
1459		that case session-averaged Δ4x values are statistically independent).
1460		'''
1461		unknowns_old = sorted({s for s in self.unknowns})
1462		CM_old = self.standardization.covar[:,:]
1463		VD_old = self.standardization.params.valuesdict().copy()
1464		vars_old = self.standardization.var_names
1465
1466		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
1467
1468		Ns = len(vars_old) - len(unknowns_old)
1469		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
1470		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
1471
1472		W = np.zeros((len(vars_new), len(vars_old)))
1473		W[:Ns,:Ns] = np.eye(Ns)
1474		for u in unknowns_new:
1475			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
1476			if self.grouping == 'by_session':
1477				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
1478			elif self.grouping == 'by_uid':
1479				weights = [1 for s in splits]
1480			sw = sum(weights)
1481			weights = [w/sw for w in weights]
1482			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
1483
1484		CM_new = W @ CM_old @ W.T
1485		V = W @ np.array([[VD_old[k]] for k in vars_old])
1486		VD_new = {k:v[0] for k,v in zip(vars_new, V)}
1487
1488		self.standardization.covar = CM_new
1489		self.standardization.params.valuesdict = lambda : VD_new
1490		self.standardization.var_names = vars_new
1491
1492		for r in self:
1493			if r['Sample'] in self.unknowns:
1494				r['Sample_split'] = r['Sample']
1495				r['Sample'] = r['Sample_original']
1496
1497		self.refresh_samples()
1498		self.consolidate_samples()
1499		self.repeatabilities()
1500
1501		if tables:
1502			self.table_of_analyses()
1503			self.table_of_samples()
=======
            
1451	def unsplit_samples(self, tables = False):
1452		'''
1453		Reverse the effects of `D47data.split_samples()`.
1454		
1455		This should only be used after `D4xdata.standardize()` with `method='pooled'`.
1456		
1457		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
1458		probably use `D4xdata.combine_samples()` instead to reverse the effects of
1459		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
1460		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
1461		that case session-averaged Δ4x values are statistically independent).
1462		'''
1463		unknowns_old = sorted({s for s in self.unknowns})
1464		CM_old = self.standardization.covar[:,:]
1465		VD_old = self.standardization.params.valuesdict().copy()
1466		vars_old = self.standardization.var_names
1467
1468		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
1469
1470		Ns = len(vars_old) - len(unknowns_old)
1471		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
1472		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
1473
1474		W = np.zeros((len(vars_new), len(vars_old)))
1475		W[:Ns,:Ns] = np.eye(Ns)
1476		for u in unknowns_new:
1477			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
1478			if self.grouping == 'by_session':
1479				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
1480			elif self.grouping == 'by_uid':
1481				weights = [1 for s in splits]
1482			sw = sum(weights)
1483			weights = [w/sw for w in weights]
1484			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
1485
1486		CM_new = W @ CM_old @ W.T
1487		V = W @ np.array([[VD_old[k]] for k in vars_old])
1488		VD_new = {k:v[0] for k,v in zip(vars_new, V)}
1489
1490		self.standardization.covar = CM_new
1491		self.standardization.params.valuesdict = lambda : VD_new
1492		self.standardization.var_names = vars_new
1493
1494		for r in self:
1495			if r['Sample'] in self.unknowns:
1496				r['Sample_split'] = r['Sample']
1497				r['Sample'] = r['Sample_original']
1498
1499		self.refresh_samples()
1500		self.consolidate_samples()
1501		self.repeatabilities()
1502
1503		if tables:
1504			self.table_of_analyses()
1505			self.table_of_samples()
>>>>>>> master

Reverse the effects of D47data.split_samples().

This should only be used after D4xdata.standardize() with method='pooled'.

After D4xdata.standardize() with method='indep_sessions', one should probably use D4xdata.combine_samples() instead to reverse the effects of D47data.split_samples() with grouping='by_uid', or w_avg() to reverse the effects of D47data.split_samples() with grouping='by_sessions' (because in that case session-averaged Δ4x values are statistically independent).

def assign_timestamps(self):
<<<<<<< HEAD
1505	def assign_timestamps(self):
1506		'''
1507		Assign a time field `t` of type `float` to each analysis.
1508
1509		If `TimeTag` is one of the data fields, `t` is equal within a given session
1510		to `TimeTag` minus the mean value of `TimeTag` for that session.
1511		Otherwise, `TimeTag` is by default equal to the index of each analysis
1512		in the dataset and `t` is defined as above.
1513		'''
1514		for session in self.sessions:
1515			sdata = self.sessions[session]['data']
1516			try:
1517				t0 = np.mean([r['TimeTag'] for r in sdata])
1518				for r in sdata:
1519					r['t'] = r['TimeTag'] - t0
1520			except KeyError:
1521				t0 = (len(sdata)-1)/2
1522				for t,r in enumerate(sdata):
1523					r['t'] = t - t0
=======
            
1507	def assign_timestamps(self):
1508		'''
1509		Assign a time field `t` of type `float` to each analysis.
1510
1511		If `TimeTag` is one of the data fields, `t` is equal within a given session
1512		to `TimeTag` minus the mean value of `TimeTag` for that session.
1513		Otherwise, `TimeTag` is by default equal to the index of each analysis
1514		in the dataset and `t` is defined as above.
1515		'''
1516		for session in self.sessions:
1517			sdata = self.sessions[session]['data']
1518			try:
1519				t0 = np.mean([r['TimeTag'] for r in sdata])
1520				for r in sdata:
1521					r['t'] = r['TimeTag'] - t0
1522			except KeyError:
1523				t0 = (len(sdata)-1)/2
1524				for t,r in enumerate(sdata):
1525					r['t'] = t - t0
>>>>>>> master

Assign a time field t of type float to each analysis.

If TimeTag is one of the data fields, t is equal within a given session to TimeTag minus the mean value of TimeTag for that session. Otherwise, TimeTag is by default equal to the index of each analysis in the dataset and t is defined as above.

def report(self):
<<<<<<< HEAD
1526	def report(self):
1527		'''
1528		Prints a report on the standardization fit.
1529		Only applicable after `D4xdata.standardize(method='pooled')`.
1530		'''
1531		report_fit(self.standardization)
=======
            
1528	def report(self):
1529		'''
1530		Prints a report on the standardization fit.
1531		Only applicable after `D4xdata.standardize(method='pooled')`.
1532		'''
1533		report_fit(self.standardization)
>>>>>>> master

Prints a report on the standardization fit. Only applicable after D4xdata.standardize(method='pooled').

def combine_samples(self, sample_groups):
<<<<<<< HEAD
1534	def combine_samples(self, sample_groups):
1535		'''
1536		Combine analyses of different samples to compute weighted average Δ4x
1537		and new error (co)variances corresponding to the groups defined by the `sample_groups`
1538		dictionary.
1539		
1540		Caution: samples are weighted by number of replicate analyses, which is a
1541		reasonable default behavior but is not always optimal (e.g., in the case of strongly
1542		correlated analytical errors for one or more samples).
1543		
1544		Returns a tuplet of:
1545		
1546		+ the list of group names
1547		+ an array of the corresponding Δ4x values
1548		+ the corresponding (co)variance matrix
1549		
1550		**Parameters**
1551
1552		+ `sample_groups`: a dictionary of the form:
1553		```py
1554		{'group1': ['sample_1', 'sample_2'],
1555		 'group2': ['sample_3', 'sample_4', 'sample_5']}
1556		```
1557		'''
1558		
1559		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
1560		groups = sorted(sample_groups.keys())
1561		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
1562		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
1563		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
1564		W = np.array([
1565			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
1566			for j in groups])
1567		D4x_new = W @ D4x_old
1568		CM_new = W @ CM_old @ W.T
1569
1570		return groups, D4x_new[:,0], CM_new
=======
            
1536	def combine_samples(self, sample_groups):
1537		'''
1538		Combine analyses of different samples to compute weighted average Δ4x
1539		and new error (co)variances corresponding to the groups defined by the `sample_groups`
1540		dictionary.
1541		
1542		Caution: samples are weighted by number of replicate analyses, which is a
1543		reasonable default behavior but is not always optimal (e.g., in the case of strongly
1544		correlated analytical errors for one or more samples).
1545		
1546		Returns a tuplet of:
1547		
1548		+ the list of group names
1549		+ an array of the corresponding Δ4x values
1550		+ the corresponding (co)variance matrix
1551		
1552		**Parameters**
1553
1554		+ `sample_groups`: a dictionary of the form:
1555		```py
1556		{'group1': ['sample_1', 'sample_2'],
1557		 'group2': ['sample_3', 'sample_4', 'sample_5']}
1558		```
1559		'''
1560		
1561		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
1562		groups = sorted(sample_groups.keys())
1563		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
1564		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
1565		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
1566		W = np.array([
1567			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
1568			for j in groups])
1569		D4x_new = W @ D4x_old
1570		CM_new = W @ CM_old @ W.T
1571
1572		return groups, D4x_new[:,0], CM_new
>>>>>>> master

Combine analyses of different samples to compute weighted average Δ4x and new error (co)variances corresponding to the groups defined by the sample_groups dictionary.

Caution: samples are weighted by number of replicate analyses, which is a reasonable default behavior but is not always optimal (e.g., in the case of strongly correlated analytical errors for one or more samples).

Returns a tuplet of:

  • the list of group names
  • an array of the corresponding Δ4x values
  • the corresponding (co)variance matrix

Parameters

  • sample_groups: a dictionary of the form:
{'group1': ['sample_1', 'sample_2'],
 'group2': ['sample_3', 'sample_4', 'sample_5']}
@make_verbal
def standardize( self, method='pooled', weighted_sessions=[], consolidate=True, consolidate_tables=False, consolidate_plots=False, constraints={}):
<<<<<<< HEAD
1573	@make_verbal
1574	def standardize(self,
1575		method = 'pooled',
1576		weighted_sessions = [],
1577		consolidate = True,
1578		consolidate_tables = False,
1579		consolidate_plots = False,
1580		constraints = {},
1581		):
1582		'''
1583		Compute absolute Δ4x values for all replicate analyses and for sample averages.
1584		If `method` argument is set to `'pooled'`, the standardization processes all sessions
1585		in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
1586		i.e. that their true Δ4x value does not change between sessions,
1587		([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
1588		`'indep_sessions'`, the standardization processes each session independently, based only
1589		on anchors analyses.
1590		'''
1591
1592		self.standardization_method = method
1593		self.assign_timestamps()
1594
1595		if method == 'pooled':
1596			if weighted_sessions:
1597				for session_group in weighted_sessions:
1598					if self._4x == '47':
1599						X = D47data([r for r in self if r['Session'] in session_group])
1600					elif self._4x == '48':
1601						X = D48data([r for r in self if r['Session'] in session_group])
1602					X.Nominal_D4x = self.Nominal_D4x.copy()
1603					X.refresh()
1604					result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
1605					w = np.sqrt(result.redchi)
1606					self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
1607					for r in X:
1608						r[f'wD{self._4x}raw'] *= w
1609			else:
1610				self.msg(f'All D{self._4x}raw weights set to 1 ‰')
1611				for r in self:
1612					r[f'wD{self._4x}raw'] = 1.
1613
1614			params = Parameters()
1615			for k,session in enumerate(self.sessions):
1616				self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
1617				self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
1618				self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
1619				s = pf(session)
1620				params.add(f'a_{s}', value = 0.9)
1621				params.add(f'b_{s}', value = 0.)
1622				params.add(f'c_{s}', value = -0.9)
1623				params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift'])
1624				params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift'])
1625				params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift'])
1626			for sample in self.unknowns:
1627				params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)
1628
1629			for k in constraints:
1630				params[k].expr = constraints[k]
1631
1632			def residuals(p):
1633				R = []
1634				for r in self:
1635					session = pf(r['Session'])
1636					sample = pf(r['Sample'])
1637					if r['Sample'] in self.Nominal_D4x:
1638						R += [ (
1639							r[f'D{self._4x}raw'] - (
1640								p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
1641								+ p[f'b_{session}'] * r[f'd{self._4x}']
1642								+	p[f'c_{session}']
1643								+ r['t'] * (
1644									p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
1645									+ p[f'b2_{session}'] * r[f'd{self._4x}']
1646									+	p[f'c2_{session}']
1647									)
1648								)
1649							) / r[f'wD{self._4x}raw'] ]
1650					else:
1651						R += [ (
1652							r[f'D{self._4x}raw'] - (
1653								p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
1654								+ p[f'b_{session}'] * r[f'd{self._4x}']
1655								+	p[f'c_{session}']
1656								+ r['t'] * (
1657									p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
1658									+ p[f'b2_{session}'] * r[f'd{self._4x}']
1659									+	p[f'c2_{session}']
1660									)
1661								)
1662							) / r[f'wD{self._4x}raw'] ]
1663				return R
1664
1665			M = Minimizer(residuals, params)
1666			result = M.least_squares()
1667			self.Nf = result.nfree
1668			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
1669# 			if self.verbose:
1670# 				report_fit(result)
1671
1672			for r in self:
1673				s = pf(r["Session"])
1674				a = result.params.valuesdict()[f'a_{s}']
1675				b = result.params.valuesdict()[f'b_{s}']
1676				c = result.params.valuesdict()[f'c_{s}']
1677				a2 = result.params.valuesdict()[f'a2_{s}']
1678				b2 = result.params.valuesdict()[f'b2_{s}']
1679				c2 = result.params.valuesdict()[f'c2_{s}']
1680				r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
1681
1682			self.standardization = result
1683
1684			for session in self.sessions:
1685				self.sessions[session]['Np'] = 3
1686				for k in ['scrambling', 'slope', 'wg']:
1687					if self.sessions[session][f'{k}_drift']:
1688						self.sessions[session]['Np'] += 1
1689
1690			if consolidate:
1691				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
1692			return result
1693
1694
1695		elif method == 'indep_sessions':
1696
1697			if weighted_sessions:
1698				for session_group in weighted_sessions:
1699					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
1700					X.Nominal_D4x = self.Nominal_D4x.copy()
1701					X.refresh()
1702					# This is only done to assign r['wD47raw'] for r in X:
1703					X.standardize(method = method, weighted_sessions = [], consolidate = False)
1704					self.msg(f'D{self._4x}raw weights set to {1000*X[0][f"wD{self._4x}raw"]:.1f} ppm for sessions in {session_group}')
1705			else:
1706				self.msg('All weights set to 1 ‰')
1707				for r in self:
1708					r[f'wD{self._4x}raw'] = 1
1709
1710			for session in self.sessions:
1711				s = self.sessions[session]
1712				p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
1713				p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
1714				s['Np'] = sum(p_active)
1715				sdata = s['data']
1716
1717				A = np.array([
1718					[
1719						self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
1720						r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
1721						1 / r[f'wD{self._4x}raw'],
1722						self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
1723						r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
1724						r['t'] / r[f'wD{self._4x}raw']
1725						]
1726					for r in sdata if r['Sample'] in self.anchors
1727					])[:,p_active] # only keep columns for the active parameters
1728				Y = np.array([[r[f'D{self._4x}raw'] / r[f'wD{self._4x}raw']] for r in sdata if r['Sample'] in self.anchors])
1729				s['Na'] = Y.size
1730				CM = linalg.inv(A.T @ A)
1731				bf = (CM @ A.T @ Y).T[0,:]
1732				k = 0
1733				for n,a in zip(p_names, p_active):
1734					if a:
1735						s[n] = bf[k]
1736# 						self.msg(f'{n} = {bf[k]}')
1737						k += 1
1738					else:
1739						s[n] = 0.
1740# 						self.msg(f'{n} = 0.0')
1741
1742				for r in sdata :
1743					a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
1744					r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
1745					r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])
1746
1747				s['CM'] = np.zeros((6,6))
1748				i = 0
1749				k_active = [j for j,a in enumerate(p_active) if a]
1750				for j,a in enumerate(p_active):
1751					if a:
1752						s['CM'][j,k_active] = CM[i,:]
1753						i += 1
1754
1755			if not weighted_sessions:
1756				w = self.rmswd()['rmswd']
1757				for r in self:
1758						r[f'wD{self._4x}'] *= w
1759						r[f'wD{self._4x}raw'] *= w
1760				for session in self.sessions:
1761					self.sessions[session]['CM'] *= w**2
1762
1763			for session in self.sessions:
1764				s = self.sessions[session]
1765				s['SE_a'] = s['CM'][0,0]**.5
1766				s['SE_b'] = s['CM'][1,1]**.5
1767				s['SE_c'] = s['CM'][2,2]**.5
1768				s['SE_a2'] = s['CM'][3,3]**.5
1769				s['SE_b2'] = s['CM'][4,4]**.5
1770				s['SE_c2'] = s['CM'][5,5]**.5
1771
1772			if not weighted_sessions:
1773				self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
1774			else:
1775				self.Nf = 0
1776				for sg in weighted_sessions:
1777					self.Nf += self.rmswd(sessions = sg)['Nf']
1778
1779			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
1780
1781			avgD4x = {
1782				sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
1783				for sample in self.samples
1784				}
1785			chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
1786			rD4x = (chi2/self.Nf)**.5
1787			self.repeatability[f'sigma_{self._4x}'] = rD4x
1788
1789			if consolidate:
1790				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
=======
            
1575	@make_verbal
1576	def standardize(self,
1577		method = 'pooled',
1578		weighted_sessions = [],
1579		consolidate = True,
1580		consolidate_tables = False,
1581		consolidate_plots = False,
1582		constraints = {},
1583		):
1584		'''
1585		Compute absolute Δ4x values for all replicate analyses and for sample averages.
1586		If `method` argument is set to `'pooled'`, the standardization processes all sessions
1587		in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
1588		i.e. that their true Δ4x value does not change between sessions,
1589		([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
1590		`'indep_sessions'`, the standardization processes each session independently, based only
1591		on anchors analyses.
1592		'''
1593
1594		self.standardization_method = method
1595		self.assign_timestamps()
1596
1597		if method == 'pooled':
1598			if weighted_sessions:
1599				for session_group in weighted_sessions:
1600					if self._4x == '47':
1601						X = D47data([r for r in self if r['Session'] in session_group])
1602					elif self._4x == '48':
1603						X = D48data([r for r in self if r['Session'] in session_group])
1604					X.Nominal_D4x = self.Nominal_D4x.copy()
1605					X.refresh()
1606					result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
1607					w = np.sqrt(result.redchi)
1608					self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
1609					for r in X:
1610						r[f'wD{self._4x}raw'] *= w
1611			else:
1612				self.msg(f'All D{self._4x}raw weights set to 1 ‰')
1613				for r in self:
1614					r[f'wD{self._4x}raw'] = 1.
1615
1616			params = Parameters()
1617			for k,session in enumerate(self.sessions):
1618				self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
1619				self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
1620				self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
1621				s = pf(session)
1622				params.add(f'a_{s}', value = 0.9)
1623				params.add(f'b_{s}', value = 0.)
1624				params.add(f'c_{s}', value = -0.9)
1625				params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift'])
1626				params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift'])
1627				params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift'])
1628			for sample in self.unknowns:
1629				params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)
1630
1631			for k in constraints:
1632				params[k].expr = constraints[k]
1633
1634			def residuals(p):
1635				R = []
1636				for r in self:
1637					session = pf(r['Session'])
1638					sample = pf(r['Sample'])
1639					if r['Sample'] in self.Nominal_D4x:
1640						R += [ (
1641							r[f'D{self._4x}raw'] - (
1642								p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
1643								+ p[f'b_{session}'] * r[f'd{self._4x}']
1644								+	p[f'c_{session}']
1645								+ r['t'] * (
1646									p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
1647									+ p[f'b2_{session}'] * r[f'd{self._4x}']
1648									+	p[f'c2_{session}']
1649									)
1650								)
1651							) / r[f'wD{self._4x}raw'] ]
1652					else:
1653						R += [ (
1654							r[f'D{self._4x}raw'] - (
1655								p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
1656								+ p[f'b_{session}'] * r[f'd{self._4x}']
1657								+	p[f'c_{session}']
1658								+ r['t'] * (
1659									p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
1660									+ p[f'b2_{session}'] * r[f'd{self._4x}']
1661									+	p[f'c2_{session}']
1662									)
1663								)
1664							) / r[f'wD{self._4x}raw'] ]
1665				return R
1666
1667			M = Minimizer(residuals, params)
1668			result = M.least_squares()
1669			self.Nf = result.nfree
1670			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
1671# 			if self.verbose:
1672# 				report_fit(result)
1673
1674			for r in self:
1675				s = pf(r["Session"])
1676				a = result.params.valuesdict()[f'a_{s}']
1677				b = result.params.valuesdict()[f'b_{s}']
1678				c = result.params.valuesdict()[f'c_{s}']
1679				a2 = result.params.valuesdict()[f'a2_{s}']
1680				b2 = result.params.valuesdict()[f'b2_{s}']
1681				c2 = result.params.valuesdict()[f'c2_{s}']
1682				r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
1683
1684			self.standardization = result
1685
1686			for session in self.sessions:
1687				self.sessions[session]['Np'] = 3
1688				for k in ['scrambling', 'slope', 'wg']:
1689					if self.sessions[session][f'{k}_drift']:
1690						self.sessions[session]['Np'] += 1
1691
1692			if consolidate:
1693				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
1694			return result
1695
1696
1697		elif method == 'indep_sessions':
1698
1699			if weighted_sessions:
1700				for session_group in weighted_sessions:
1701					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
1702					X.Nominal_D4x = self.Nominal_D4x.copy()
1703					X.refresh()
1704					# This is only done to assign r['wD47raw'] for r in X:
1705					X.standardize(method = method, weighted_sessions = [], consolidate = False)
1706					self.msg(f'D{self._4x}raw weights set to {1000*X[0][f"wD{self._4x}raw"]:.1f} ppm for sessions in {session_group}')
1707			else:
1708				self.msg('All weights set to 1 ‰')
1709				for r in self:
1710					r[f'wD{self._4x}raw'] = 1
1711
1712			for session in self.sessions:
1713				s = self.sessions[session]
1714				p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
1715				p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
1716				s['Np'] = sum(p_active)
1717				sdata = s['data']
1718
1719				A = np.array([
1720					[
1721						self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
1722						r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
1723						1 / r[f'wD{self._4x}raw'],
1724						self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
1725						r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
1726						r['t'] / r[f'wD{self._4x}raw']
1727						]
1728					for r in sdata if r['Sample'] in self.anchors
1729					])[:,p_active] # only keep columns for the active parameters
1730				Y = np.array([[r[f'D{self._4x}raw'] / r[f'wD{self._4x}raw']] for r in sdata if r['Sample'] in self.anchors])
1731				s['Na'] = Y.size
1732				CM = linalg.inv(A.T @ A)
1733				bf = (CM @ A.T @ Y).T[0,:]
1734				k = 0
1735				for n,a in zip(p_names, p_active):
1736					if a:
1737						s[n] = bf[k]
1738# 						self.msg(f'{n} = {bf[k]}')
1739						k += 1
1740					else:
1741						s[n] = 0.
1742# 						self.msg(f'{n} = 0.0')
1743
1744				for r in sdata :
1745					a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
1746					r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
1747					r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])
1748
1749				s['CM'] = np.zeros((6,6))
1750				i = 0
1751				k_active = [j for j,a in enumerate(p_active) if a]
1752				for j,a in enumerate(p_active):
1753					if a:
1754						s['CM'][j,k_active] = CM[i,:]
1755						i += 1
1756
1757			if not weighted_sessions:
1758				w = self.rmswd()['rmswd']
1759				for r in self:
1760						r[f'wD{self._4x}'] *= w
1761						r[f'wD{self._4x}raw'] *= w
1762				for session in self.sessions:
1763					self.sessions[session]['CM'] *= w**2
1764
1765			for session in self.sessions:
1766				s = self.sessions[session]
1767				s['SE_a'] = s['CM'][0,0]**.5
1768				s['SE_b'] = s['CM'][1,1]**.5
1769				s['SE_c'] = s['CM'][2,2]**.5
1770				s['SE_a2'] = s['CM'][3,3]**.5
1771				s['SE_b2'] = s['CM'][4,4]**.5
1772				s['SE_c2'] = s['CM'][5,5]**.5
1773
1774			if not weighted_sessions:
1775				self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
1776			else:
1777				self.Nf = 0
1778				for sg in weighted_sessions:
1779					self.Nf += self.rmswd(sessions = sg)['Nf']
1780
1781			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
1782
1783			avgD4x = {
1784				sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
1785				for sample in self.samples
1786				}
1787			chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
1788			rD4x = (chi2/self.Nf)**.5
1789			self.repeatability[f'sigma_{self._4x}'] = rD4x
1790
1791			if consolidate:
1792				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
>>>>>>> master

Compute absolute Δ4x values for all replicate analyses and for sample averages. If method argument is set to 'pooled', the standardization processes all sessions in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous, i.e. that their true Δ4x value does not change between sessions, (Daëron, 2021). If method argument is set to 'indep_sessions', the standardization processes each session independently, based only on anchors analyses.

def standardization_error(self, session, d4x, D4x, t=0):
<<<<<<< HEAD
1793	def standardization_error(self, session, d4x, D4x, t = 0):
1794		'''
1795		Compute standardization error for a given session and
1796		(δ47, Δ47) composition.
1797		'''
1798		a = self.sessions[session]['a']
1799		b = self.sessions[session]['b']
1800		c = self.sessions[session]['c']
1801		a2 = self.sessions[session]['a2']
1802		b2 = self.sessions[session]['b2']
1803		c2 = self.sessions[session]['c2']
1804		CM = self.sessions[session]['CM']
1805
1806		x, y = D4x, d4x
1807		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
1808# 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
1809		dxdy = -(b+b2*t) / (a+a2*t)
1810		dxdz = 1. / (a+a2*t)
1811		dxda = -x / (a+a2*t)
1812		dxdb = -y / (a+a2*t)
1813		dxdc = -1. / (a+a2*t)
1814		dxda2 = -x * a2 / (a+a2*t)
1815		dxdb2 = -y * t / (a+a2*t)
1816		dxdc2 = -t / (a+a2*t)
1817		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
1818		sx = (V @ CM @ V.T) ** .5
1819		return sx
=======
            
1795	def standardization_error(self, session, d4x, D4x, t = 0):
1796		'''
1797		Compute standardization error for a given session and
1798		(δ47, Δ47) composition.
1799		'''
1800		a = self.sessions[session]['a']
1801		b = self.sessions[session]['b']
1802		c = self.sessions[session]['c']
1803		a2 = self.sessions[session]['a2']
1804		b2 = self.sessions[session]['b2']
1805		c2 = self.sessions[session]['c2']
1806		CM = self.sessions[session]['CM']
1807
1808		x, y = D4x, d4x
1809		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
1810# 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
1811		dxdy = -(b+b2*t) / (a+a2*t)
1812		dxdz = 1. / (a+a2*t)
1813		dxda = -x / (a+a2*t)
1814		dxdb = -y / (a+a2*t)
1815		dxdc = -1. / (a+a2*t)
1816		dxda2 = -x * a2 / (a+a2*t)
1817		dxdb2 = -y * t / (a+a2*t)
1818		dxdc2 = -t / (a+a2*t)
1819		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
1820		sx = (V @ CM @ V.T) ** .5
1821		return sx
>>>>>>> master

Compute standardization error for a given session and (δ47, Δ47) composition.

@make_verbal
def summary(self, dir='output', filename=None, save_to_file=True, print_out=True):
<<<<<<< HEAD
1822	@make_verbal
1823	def summary(self,
1824		dir = 'output',
1825		filename = None,
1826		save_to_file = True,
1827		print_out = True,
1828		):
1829		'''
1830		Print out an/or save to disk a summary of the standardization results.
1831
1832		**Parameters**
1833
1834		+ `dir`: the directory in which to save the table
1835		+ `filename`: the name to the csv file to write to
1836		+ `save_to_file`: whether to save the table to disk
1837		+ `print_out`: whether to print out the table
1838		'''
1839
1840		out = []
1841		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
1842		out += [['N analyses (anchors + unknowns)', f"{len(self)} ({len([r for r in self if r['Sample'] in self.anchors])} + {len([r for r in self if r['Sample'] in self.unknowns])})"]]
1843		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
1844		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
1845		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
1846		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
1847		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
1848		out += [['Model degrees of freedom', f"{self.Nf}"]]
1849		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
1850		out += [['Standardization method', self.standardization_method]]
1851
1852		if save_to_file:
1853			if not os.path.exists(dir):
1854				os.makedirs(dir)
1855			if filename is None:
1856				filename = f'D{self._4x}_summary.csv'
1857			with open(f'{dir}/{filename}', 'w') as fid:
1858				fid.write(make_csv(out))
1859		if print_out:
1860			self.msg('\n' + pretty_table(out, header = 0))
=======
            
1824	@make_verbal
1825	def summary(self,
1826		dir = 'output',
1827		filename = None,
1828		save_to_file = True,
1829		print_out = True,
1830		):
1831		'''
1832		Print out an/or save to disk a summary of the standardization results.
1833
1834		**Parameters**
1835
1836		+ `dir`: the directory in which to save the table
1837		+ `filename`: the name to the csv file to write to
1838		+ `save_to_file`: whether to save the table to disk
1839		+ `print_out`: whether to print out the table
1840		'''
1841
1842		out = []
1843		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
1844		out += [['N analyses (anchors + unknowns)', f"{len(self)} ({len([r for r in self if r['Sample'] in self.anchors])} + {len([r for r in self if r['Sample'] in self.unknowns])})"]]
1845		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
1846		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
1847		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
1848		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
1849		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
1850		out += [['Model degrees of freedom', f"{self.Nf}"]]
1851		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
1852		out += [['Standardization method', self.standardization_method]]
1853
1854		if save_to_file:
1855			if not os.path.exists(dir):
1856				os.makedirs(dir)
1857			if filename is None:
1858				filename = f'D{self._4x}_summary.csv'
1859			with open(f'{dir}/{filename}', 'w') as fid:
1860				fid.write(make_csv(out))
1861		if print_out:
1862			self.msg('\n' + pretty_table(out, header = 0))
>>>>>>> master

Print out an/or save to disk a summary of the standardization results.

Parameters

  • dir: the directory in which to save the table
  • filename: the name to the csv file to write to
  • save_to_file: whether to save the table to disk
  • print_out: whether to print out the table
@make_verbal
def table_of_sessions( self, dir='output', filename=None, save_to_file=True, print_out=True, output=None):
<<<<<<< HEAD
1863	@make_verbal
1864	def table_of_sessions(self,
1865		dir = 'output',
1866		filename = None,
1867		save_to_file = True,
1868		print_out = True,
1869		output = None,
1870		):
1871		'''
1872		Print out an/or save to disk a table of sessions.
1873
1874		**Parameters**
1875
1876		+ `dir`: the directory in which to save the table
1877		+ `filename`: the name to the csv file to write to
1878		+ `save_to_file`: whether to save the table to disk
1879		+ `print_out`: whether to print out the table
1880		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
1881		    if set to `'raw'`: return a list of list of strings
1882		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
1883		'''
1884		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
1885		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
1886		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
1887
1888		out = [['Session','Na','Nu','d13Cwg_VPDB','d18Owg_VSMOW','r_d13C','r_d18O',f'r_D{self._4x}','a ± SE','1e3 x b ± SE','c ± SE']]
1889		if include_a2:
1890			out[-1] += ['a2 ± SE']
1891		if include_b2:
1892			out[-1] += ['b2 ± SE']
1893		if include_c2:
1894			out[-1] += ['c2 ± SE']
1895		for session in self.sessions:
1896			out += [[
1897				session,
1898				f"{self.sessions[session]['Na']}",
1899				f"{self.sessions[session]['Nu']}",
1900				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
1901				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
1902				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
1903				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
1904				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
1905				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
1906				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
1907				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
1908				]]
1909			if include_a2:
1910				if self.sessions[session]['scrambling_drift']:
1911					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
1912				else:
1913					out[-1] += ['']
1914			if include_b2:
1915				if self.sessions[session]['slope_drift']:
1916					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
1917				else:
1918					out[-1] += ['']
1919			if include_c2:
1920				if self.sessions[session]['wg_drift']:
1921					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
1922				else:
1923					out[-1] += ['']
1924
1925		if save_to_file:
1926			if not os.path.exists(dir):
1927				os.makedirs(dir)
1928			if filename is None:
1929				filename = f'D{self._4x}_sessions.csv'
1930			with open(f'{dir}/{filename}', 'w') as fid:
1931				fid.write(make_csv(out))
1932		if print_out:
1933			self.msg('\n' + pretty_table(out))
1934		if output == 'raw':
1935			return out
1936		elif output == 'pretty':
1937			return pretty_table(out)
=======
            
1865	@make_verbal
1866	def table_of_sessions(self,
1867		dir = 'output',
1868		filename = None,
1869		save_to_file = True,
1870		print_out = True,
1871		output = None,
1872		):
1873		'''
1874		Print out an/or save to disk a table of sessions.
1875
1876		**Parameters**
1877
1878		+ `dir`: the directory in which to save the table
1879		+ `filename`: the name to the csv file to write to
1880		+ `save_to_file`: whether to save the table to disk
1881		+ `print_out`: whether to print out the table
1882		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
1883		    if set to `'raw'`: return a list of list of strings
1884		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
1885		'''
1886		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
1887		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
1888		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
1889
1890		out = [['Session','Na','Nu','d13Cwg_VPDB','d18Owg_VSMOW','r_d13C','r_d18O',f'r_D{self._4x}','a ± SE','1e3 x b ± SE','c ± SE']]
1891		if include_a2:
1892			out[-1] += ['a2 ± SE']
1893		if include_b2:
1894			out[-1] += ['b2 ± SE']
1895		if include_c2:
1896			out[-1] += ['c2 ± SE']
1897		for session in self.sessions:
1898			out += [[
1899				session,
1900				f"{self.sessions[session]['Na']}",
1901				f"{self.sessions[session]['Nu']}",
1902				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
1903				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
1904				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
1905				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
1906				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
1907				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
1908				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
1909				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
1910				]]
1911			if include_a2:
1912				if self.sessions[session]['scrambling_drift']:
1913					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
1914				else:
1915					out[-1] += ['']
1916			if include_b2:
1917				if self.sessions[session]['slope_drift']:
1918					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
1919				else:
1920					out[-1] += ['']
1921			if include_c2:
1922				if self.sessions[session]['wg_drift']:
1923					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
1924				else:
1925					out[-1] += ['']
1926
1927		if save_to_file:
1928			if not os.path.exists(dir):
1929				os.makedirs(dir)
1930			if filename is None:
1931				filename = f'D{self._4x}_sessions.csv'
1932			with open(f'{dir}/{filename}', 'w') as fid:
1933				fid.write(make_csv(out))
1934		if print_out:
1935			self.msg('\n' + pretty_table(out))
1936		if output == 'raw':
1937			return out
1938		elif output == 'pretty':
1939			return pretty_table(out)
>>>>>>> master

Print out an/or save to disk a table of sessions.

Parameters

  • dir: the directory in which to save the table
  • filename: the name to the csv file to write to
  • save_to_file: whether to save the table to disk
  • print_out: whether to print out the table
  • output: if set to 'pretty': return a pretty text table (see pretty_table()); if set to 'raw': return a list of list of strings (e.g., [['header1', 'header2'], ['0.1', '0.2']])
@make_verbal
def table_of_analyses( self, dir='output', filename=None, save_to_file=True, print_out=True, output=None):
<<<<<<< HEAD
1940	@make_verbal
1941	def table_of_analyses(
1942		self,
1943		dir = 'output',
1944		filename = None,
1945		save_to_file = True,
1946		print_out = True,
1947		output = None,
1948		):
1949		'''
1950		Print out an/or save to disk a table of analyses.
1951
1952		**Parameters**
1953
1954		+ `dir`: the directory in which to save the table
1955		+ `filename`: the name to the csv file to write to
1956		+ `save_to_file`: whether to save the table to disk
1957		+ `print_out`: whether to print out the table
1958		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
1959		    if set to `'raw'`: return a list of list of strings
1960		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
1961		'''
1962
1963		out = [['UID','Session','Sample']]
1964		extra_fields = [f for f in [('SampleMass','.2f'),('ColdFingerPressure','.1f'),('AcidReactionYield','.3f')] if f[0] in {k for r in self for k in r}]
1965		for f in extra_fields:
1966			out[-1] += [f[0]]
1967		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
1968		for r in self:
1969			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
1970			for f in extra_fields:
1971				out[-1] += [f"{r[f[0]]:{f[1]}}"]
1972			out[-1] += [
1973				f"{r['d13Cwg_VPDB']:.3f}",
1974				f"{r['d18Owg_VSMOW']:.3f}",
1975				f"{r['d45']:.6f}",
1976				f"{r['d46']:.6f}",
1977				f"{r['d47']:.6f}",
1978				f"{r['d48']:.6f}",
1979				f"{r['d49']:.6f}",
1980				f"{r['d13C_VPDB']:.6f}",
1981				f"{r['d18O_VSMOW']:.6f}",
1982				f"{r['D47raw']:.6f}",
1983				f"{r['D48raw']:.6f}",
1984				f"{r['D49raw']:.6f}",
1985				f"{r[f'D{self._4x}']:.6f}"
1986				]
1987		if save_to_file:
1988			if not os.path.exists(dir):
1989				os.makedirs(dir)
1990			if filename is None:
1991				filename = f'D{self._4x}_analyses.csv'
1992			with open(f'{dir}/{filename}', 'w') as fid:
1993				fid.write(make_csv(out))
1994		if print_out:
1995			self.msg('\n' + pretty_table(out))
1996		return out
=======
            
1942	@make_verbal
1943	def table_of_analyses(
1944		self,
1945		dir = 'output',
1946		filename = None,
1947		save_to_file = True,
1948		print_out = True,
1949		output = None,
1950		):
1951		'''
1952		Print out an/or save to disk a table of analyses.
1953
1954		**Parameters**
1955
1956		+ `dir`: the directory in which to save the table
1957		+ `filename`: the name to the csv file to write to
1958		+ `save_to_file`: whether to save the table to disk
1959		+ `print_out`: whether to print out the table
1960		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
1961		    if set to `'raw'`: return a list of list of strings
1962		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
1963		'''
1964
1965		out = [['UID','Session','Sample']]
1966		extra_fields = [f for f in [('SampleMass','.2f'),('ColdFingerPressure','.1f'),('AcidReactionYield','.3f')] if f[0] in {k for r in self for k in r}]
1967		for f in extra_fields:
1968			out[-1] += [f[0]]
1969		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
1970		for r in self:
1971			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
1972			for f in extra_fields:
1973				out[-1] += [f"{r[f[0]]:{f[1]}}"]
1974			out[-1] += [
1975				f"{r['d13Cwg_VPDB']:.3f}",
1976				f"{r['d18Owg_VSMOW']:.3f}",
1977				f"{r['d45']:.6f}",
1978				f"{r['d46']:.6f}",
1979				f"{r['d47']:.6f}",
1980				f"{r['d48']:.6f}",
1981				f"{r['d49']:.6f}",
1982				f"{r['d13C_VPDB']:.6f}",
1983				f"{r['d18O_VSMOW']:.6f}",
1984				f"{r['D47raw']:.6f}",
1985				f"{r['D48raw']:.6f}",
1986				f"{r['D49raw']:.6f}",
1987				f"{r[f'D{self._4x}']:.6f}"
1988				]
1989		if save_to_file:
1990			if not os.path.exists(dir):
1991				os.makedirs(dir)
1992			if filename is None:
1993				filename = f'D{self._4x}_analyses.csv'
1994			with open(f'{dir}/{filename}', 'w') as fid:
1995				fid.write(make_csv(out))
1996		if print_out:
1997			self.msg('\n' + pretty_table(out))
1998		return out
>>>>>>> master

Print out an/or save to disk a table of analyses.

Parameters

  • dir: the directory in which to save the table
  • filename: the name to the csv file to write to
  • save_to_file: whether to save the table to disk
  • print_out: whether to print out the table
  • output: if set to 'pretty': return a pretty text table (see pretty_table()); if set to 'raw': return a list of list of strings (e.g., [['header1', 'header2'], ['0.1', '0.2']])
@make_verbal
def covar_table( self, correl=False, dir='output', filename=None, save_to_file=True, print_out=True, output=None):
<<<<<<< HEAD
1998	@make_verbal
1999	def covar_table(
2000		self,
2001		correl = False,
2002		dir = 'output',
2003		filename = None,
2004		save_to_file = True,
2005		print_out = True,
2006		output = None,
2007		):
2008		'''
2009		Print out, save to disk and/or return the variance-covariance matrix of D4x
2010		for all unknown samples.
2011
2012		**Parameters**
2013
2014		+ `dir`: the directory in which to save the csv
2015		+ `filename`: the name of the csv file to write to
2016		+ `save_to_file`: whether to save the csv
2017		+ `print_out`: whether to print out the matrix
2018		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
2019		    if set to `'raw'`: return a list of list of strings
2020		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
2021		'''
2022		samples = sorted([u for u in self.unknowns])
2023		out = [[''] + samples]
2024		for s1 in samples:
2025			out.append([s1])
2026			for s2 in samples:
2027				if correl:
2028					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
2029				else:
2030					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
2031
2032		if save_to_file:
2033			if not os.path.exists(dir):
2034				os.makedirs(dir)
2035			if filename is None:
2036				if correl:
2037					filename = f'D{self._4x}_correl.csv'
2038				else:
2039					filename = f'D{self._4x}_covar.csv'
2040			with open(f'{dir}/{filename}', 'w') as fid:
2041				fid.write(make_csv(out))
2042		if print_out:
2043			self.msg('\n'+pretty_table(out))
2044		if output == 'raw':
2045			return out
2046		elif output == 'pretty':
2047			return pretty_table(out)
=======
            
2000	@make_verbal
2001	def covar_table(
2002		self,
2003		correl = False,
2004		dir = 'output',
2005		filename = None,
2006		save_to_file = True,
2007		print_out = True,
2008		output = None,
2009		):
2010		'''
2011		Print out, save to disk and/or return the variance-covariance matrix of D4x
2012		for all unknown samples.
2013
2014		**Parameters**
2015
2016		+ `dir`: the directory in which to save the csv
2017		+ `filename`: the name of the csv file to write to
2018		+ `save_to_file`: whether to save the csv
2019		+ `print_out`: whether to print out the matrix
2020		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
2021		    if set to `'raw'`: return a list of list of strings
2022		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
2023		'''
2024		samples = sorted([u for u in self.unknowns])
2025		out = [[''] + samples]
2026		for s1 in samples:
2027			out.append([s1])
2028			for s2 in samples:
2029				if correl:
2030					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
2031				else:
2032					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
2033
2034		if save_to_file:
2035			if not os.path.exists(dir):
2036				os.makedirs(dir)
2037			if filename is None:
2038				if correl:
2039					filename = f'D{self._4x}_correl.csv'
2040				else:
2041					filename = f'D{self._4x}_covar.csv'
2042			with open(f'{dir}/{filename}', 'w') as fid:
2043				fid.write(make_csv(out))
2044		if print_out:
2045			self.msg('\n'+pretty_table(out))
2046		if output == 'raw':
2047			return out
2048		elif output == 'pretty':
2049			return pretty_table(out)
>>>>>>> master

Print out, save to disk and/or return the variance-covariance matrix of D4x for all unknown samples.

Parameters

  • dir: the directory in which to save the csv
  • filename: the name of the csv file to write to
  • save_to_file: whether to save the csv
  • print_out: whether to print out the matrix
  • output: if set to 'pretty': return a pretty text matrix (see pretty_table()); if set to 'raw': return a list of list of strings (e.g., [['header1', 'header2'], ['0.1', '0.2']])
@make_verbal
def table_of_samples( self, dir='output', filename=None, save_to_file=True, print_out=True, output=None):
<<<<<<< HEAD
2049	@make_verbal
2050	def table_of_samples(
2051		self,
2052		dir = 'output',
2053		filename = None,
2054		save_to_file = True,
2055		print_out = True,
2056		output = None,
2057		):
2058		'''
2059		Print out, save to disk and/or return a table of samples.
2060
2061		**Parameters**
2062
2063		+ `dir`: the directory in which to save the csv
2064		+ `filename`: the name of the csv file to write to
2065		+ `save_to_file`: whether to save the csv
2066		+ `print_out`: whether to print out the table
2067		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
2068		    if set to `'raw'`: return a list of list of strings
2069		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
2070		'''
2071
2072		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
2073		for sample in self.anchors:
2074			out += [[
2075				f"{sample}",
2076				f"{self.samples[sample]['N']}",
2077				f"{self.samples[sample]['d13C_VPDB']:.2f}",
2078				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
2079				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
2080				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
2081				]]
2082		for sample in self.unknowns:
2083			out += [[
2084				f"{sample}",
2085				f"{self.samples[sample]['N']}",
2086				f"{self.samples[sample]['d13C_VPDB']:.2f}",
2087				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
2088				f"{self.samples[sample][f'D{self._4x}']:.4f}",
2089				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
2090				f{self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
2091				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
2092				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
2093				]]
2094		if save_to_file:
2095			if not os.path.exists(dir):
2096				os.makedirs(dir)
2097			if filename is None:
2098				filename = f'D{self._4x}_samples.csv'
2099			with open(f'{dir}/{filename}', 'w') as fid:
2100				fid.write(make_csv(out))
2101		if print_out:
2102			self.msg('\n'+pretty_table(out))
2103		if output == 'raw':
2104			return out
2105		elif output == 'pretty':
2106			return pretty_table(out)
=======
            
2051	@make_verbal
2052	def table_of_samples(
2053		self,
2054		dir = 'output',
2055		filename = None,
2056		save_to_file = True,
2057		print_out = True,
2058		output = None,
2059		):
2060		'''
2061		Print out, save to disk and/or return a table of samples.
2062
2063		**Parameters**
2064
2065		+ `dir`: the directory in which to save the csv
2066		+ `filename`: the name of the csv file to write to
2067		+ `save_to_file`: whether to save the csv
2068		+ `print_out`: whether to print out the table
2069		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
2070		    if set to `'raw'`: return a list of list of strings
2071		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
2072		'''
2073
2074		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
2075		for sample in self.anchors:
2076			out += [[
2077				f"{sample}",
2078				f"{self.samples[sample]['N']}",
2079				f"{self.samples[sample]['d13C_VPDB']:.2f}",
2080				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
2081				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
2082				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
2083				]]
2084		for sample in self.unknowns:
2085			out += [[
2086				f"{sample}",
2087				f"{self.samples[sample]['N']}",
2088				f"{self.samples[sample]['d13C_VPDB']:.2f}",
2089				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
2090				f"{self.samples[sample][f'D{self._4x}']:.4f}",
2091				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
2092				f{self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
2093				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
2094				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
2095				]]
2096		if save_to_file:
2097			if not os.path.exists(dir):
2098				os.makedirs(dir)
2099			if filename is None:
2100				filename = f'D{self._4x}_samples.csv'
2101			with open(f'{dir}/{filename}', 'w') as fid:
2102				fid.write(make_csv(out))
2103		if print_out:
2104			self.msg('\n'+pretty_table(out))
2105		if output == 'raw':
2106			return out
2107		elif output == 'pretty':
2108			return pretty_table(out)
>>>>>>> master

Print out, save to disk and/or return a table of samples.

Parameters

  • dir: the directory in which to save the csv
  • filename: the name of the csv file to write to
  • save_to_file: whether to save the csv
  • print_out: whether to print out the table
  • output: if set to 'pretty': return a pretty text table (see pretty_table()); if set to 'raw': return a list of list of strings (e.g., [['header1', 'header2'], ['0.1', '0.2']])
def plot_sessions(self, dir='output', figsize=(8, 8)):
<<<<<<< HEAD
2109	def plot_sessions(self, dir = 'output', figsize = (8,8)):
2110		'''
2111		Generate session plots and save them to disk.
2112
2113		**Parameters**
2114
2115		+ `dir`: the directory in which to save the plots
2116		+ `figsize`: the width and height (in inches) of each plot
2117		'''
2118		if not os.path.exists(dir):
2119			os.makedirs(dir)
2120
2121		for session in self.sessions:
2122			sp = self.plot_single_session(session, xylimits = 'constant')
2123			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
2124			ppl.close(sp.fig)
=======
            
2111	def plot_sessions(self, dir = 'output', figsize = (8,8)):
2112		'''
2113		Generate session plots and save them to disk.
2114
2115		**Parameters**
2116
2117		+ `dir`: the directory in which to save the plots
2118		+ `figsize`: the width and height (in inches) of each plot
2119		'''
2120		if not os.path.exists(dir):
2121			os.makedirs(dir)
2122
2123		for session in self.sessions:
2124			sp = self.plot_single_session(session, xylimits = 'constant')
2125			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
2126			ppl.close(sp.fig)
>>>>>>> master

Generate session plots and save them to disk.

Parameters

  • dir: the directory in which to save the plots
  • figsize: the width and height (in inches) of each plot
@make_verbal
def consolidate_samples(self):
<<<<<<< HEAD
2127	@make_verbal
2128	def consolidate_samples(self):
2129		'''
2130		Compile various statistics for each sample.
2131
2132		For each anchor sample:
2133
2134		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
2135		+ `SE_D47` or `SE_D48`: set to zero by definition
2136
2137		For each unknown sample:
2138
2139		+ `D47` or `D48`: the standardized Δ4x value for this unknown
2140		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown
2141
2142		For each anchor and unknown:
2143
2144		+ `N`: the total number of analyses of this sample
2145		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
2146		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
2147		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
2148		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
2149		variance, indicating whether the Δ4x repeatability this sample differs significantly from
2150		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
2151		'''
2152		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
2153		for sample in self.samples:
2154			self.samples[sample]['N'] = len(self.samples[sample]['data'])
2155			if self.samples[sample]['N'] > 1:
2156				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
2157
2158			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
2159			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
2160
2161			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
2162			if len(D4x_pop) > 2:
2163				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
2164
2165		if self.standardization_method == 'pooled':
2166			for sample in self.anchors:
2167				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
2168				self.samples[sample][f'SE_D{self._4x}'] = 0.
2169			for sample in self.unknowns:
2170				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
2171				try:
2172					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
2173				except ValueError:
2174					# when `sample` is constrained by self.standardize(constraints = {...}),
2175					# it is no longer listed in self.standardization.var_names.
2176					# Temporary fix: define SE as zero for now
2177					self.samples[sample][f'SE_D4{self._4x}'] = 0.
2178
2179		elif self.standardization_method == 'indep_sessions':
2180			for sample in self.anchors:
2181				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
2182				self.samples[sample][f'SE_D{self._4x}'] = 0.
2183			for sample in self.unknowns:
2184				self.msg(f'Consolidating sample {sample}')
2185				self.unknowns[sample][f'session_D{self._4x}'] = {}
2186				session_avg = []
2187				for session in self.sessions:
2188					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
2189					if sdata:
2190						self.msg(f'{sample} found in session {session}')
2191						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
2192						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
2193						# !! TODO: sigma_s below does not account for temporal changes in standardization error
2194						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
2195						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
2196						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
2197						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
2198				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
2199				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
2200				wsum = sum([weights[s] for s in weights])
2201				for s in weights:
2202					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]
=======
            
2129	@make_verbal
2130	def consolidate_samples(self):
2131		'''
2132		Compile various statistics for each sample.
2133
2134		For each anchor sample:
2135
2136		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
2137		+ `SE_D47` or `SE_D48`: set to zero by definition
2138
2139		For each unknown sample:
2140
2141		+ `D47` or `D48`: the standardized Δ4x value for this unknown
2142		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown
2143
2144		For each anchor and unknown:
2145
2146		+ `N`: the total number of analyses of this sample
2147		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
2148		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
2149		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
2150		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
2151		variance, indicating whether the Δ4x repeatability this sample differs significantly from
2152		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
2153		'''
2154		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
2155		for sample in self.samples:
2156			self.samples[sample]['N'] = len(self.samples[sample]['data'])
2157			if self.samples[sample]['N'] > 1:
2158				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
2159
2160			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
2161			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
2162
2163			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
2164			if len(D4x_pop) > 2:
2165				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
2166
2167		if self.standardization_method == 'pooled':
2168			for sample in self.anchors:
2169				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
2170				self.samples[sample][f'SE_D{self._4x}'] = 0.
2171			for sample in self.unknowns:
2172				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
2173				try:
2174					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
2175				except ValueError:
2176					# when `sample` is constrained by self.standardize(constraints = {...}),
2177					# it is no longer listed in self.standardization.var_names.
2178					# Temporary fix: define SE as zero for now
2179					self.samples[sample][f'SE_D4{self._4x}'] = 0.
2180
2181		elif self.standardization_method == 'indep_sessions':
2182			for sample in self.anchors:
2183				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
2184				self.samples[sample][f'SE_D{self._4x}'] = 0.
2185			for sample in self.unknowns:
2186				self.msg(f'Consolidating sample {sample}')
2187				self.unknowns[sample][f'session_D{self._4x}'] = {}
2188				session_avg = []
2189				for session in self.sessions:
2190					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
2191					if sdata:
2192						self.msg(f'{sample} found in session {session}')
2193						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
2194						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
2195						# !! TODO: sigma_s below does not account for temporal changes in standardization error
2196						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
2197						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
2198						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
2199						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
2200				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
2201				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
2202				wsum = sum([weights[s] for s in weights])
2203				for s in weights:
2204					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]
>>>>>>> master

Compile various statistics for each sample.

For each anchor sample:

  • D47 or D48: the nominal Δ4x value for this anchor, specified by self.Nominal_D4x
  • SE_D47 or SE_D48: set to zero by definition

For each unknown sample:

  • D47 or D48: the standardized Δ4x value for this unknown
  • SE_D47 or SE_D48: the standard error of Δ4x for this unknown

For each anchor and unknown:

  • N: the total number of analyses of this sample
  • SD_D47 or SD_D48: the “sample” (in the statistical sense) standard deviation for this sample
  • d13C_VPDB: the average δ13CVPDB value for this sample
  • d18O_VSMOW: the average δ18OVSMOW value for this sample (as CO2)
  • p_Levene: the p-value from a Levene test of equal variance, indicating whether the Δ4x repeatability this sample differs significantly from that observed for the reference sample specified by self.LEVENE_REF_SAMPLE.
def consolidate_sessions(self):
<<<<<<< HEAD
2205	def consolidate_sessions(self):
2206		'''
2207		Compute various statistics for each session.
2208
2209		+ `Na`: Number of anchor analyses in the session
2210		+ `Nu`: Number of unknown analyses in the session
2211		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
2212		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
2213		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
2214		+ `a`: scrambling factor
2215		+ `b`: compositional slope
2216		+ `c`: WG offset
2217		+ `SE_a`: Model stadard erorr of `a`
2218		+ `SE_b`: Model stadard erorr of `b`
2219		+ `SE_c`: Model stadard erorr of `c`
2220		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
2221		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
2222		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
2223		+ `a2`: scrambling factor drift
2224		+ `b2`: compositional slope drift
2225		+ `c2`: WG offset drift
2226		+ `Np`: Number of standardization parameters to fit
2227		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
2228		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
2229		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
2230		'''
2231		for session in self.sessions:
2232			if 'd13Cwg_VPDB' not in self.sessions[session]:
2233				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
2234			if 'd18Owg_VSMOW' not in self.sessions[session]:
2235				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
2236			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
2237			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
2238
2239			self.msg(f'Computing repeatabilities for session {session}')
2240			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
2241			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
2242			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
2243
2244		if self.standardization_method == 'pooled':
2245			for session in self.sessions:
2246
2247				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
2248				i = self.standardization.var_names.index(f'a_{pf(session)}')
2249				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
2250
2251				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
2252				i = self.standardization.var_names.index(f'b_{pf(session)}')
2253				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
2254
2255				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
2256				i = self.standardization.var_names.index(f'c_{pf(session)}')
2257				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
2258
2259				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
2260				if self.sessions[session]['scrambling_drift']:
2261					i = self.standardization.var_names.index(f'a2_{pf(session)}')
2262					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
2263				else:
2264					self.sessions[session]['SE_a2'] = 0.
2265
2266				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
2267				if self.sessions[session]['slope_drift']:
2268					i = self.standardization.var_names.index(f'b2_{pf(session)}')
2269					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
2270				else:
2271					self.sessions[session]['SE_b2'] = 0.
2272
2273				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
2274				if self.sessions[session]['wg_drift']:
2275					i = self.standardization.var_names.index(f'c2_{pf(session)}')
2276					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
2277				else:
2278					self.sessions[session]['SE_c2'] = 0.
2279
2280				i = self.standardization.var_names.index(f'a_{pf(session)}')
2281				j = self.standardization.var_names.index(f'b_{pf(session)}')
2282				k = self.standardization.var_names.index(f'c_{pf(session)}')
2283				CM = np.zeros((6,6))
2284				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
2285				try:
2286					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
2287					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
2288					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
2289					try:
2290						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
2291						CM[3,4] = self.standardization.covar[i2,j2]
2292						CM[4,3] = self.standardization.covar[j2,i2]
2293					except ValueError:
2294						pass
2295					try:
2296						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2297						CM[3,5] = self.standardization.covar[i2,k2]
2298						CM[5,3] = self.standardization.covar[k2,i2]
2299					except ValueError:
2300						pass
2301				except ValueError:
2302					pass
2303				try:
2304					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
2305					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
2306					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
2307					try:
2308						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2309						CM[4,5] = self.standardization.covar[j2,k2]
2310						CM[5,4] = self.standardization.covar[k2,j2]
2311					except ValueError:
2312						pass
2313				except ValueError:
2314					pass
2315				try:
2316					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2317					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
2318					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
2319				except ValueError:
2320					pass
2321
2322				self.sessions[session]['CM'] = CM
2323
2324		elif self.standardization_method == 'indep_sessions':
2325			pass # Not implemented yet
=======
            
2207	def consolidate_sessions(self):
2208		'''
2209		Compute various statistics for each session.
2210
2211		+ `Na`: Number of anchor analyses in the session
2212		+ `Nu`: Number of unknown analyses in the session
2213		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
2214		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
2215		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
2216		+ `a`: scrambling factor
2217		+ `b`: compositional slope
2218		+ `c`: WG offset
2219		+ `SE_a`: Model stadard erorr of `a`
2220		+ `SE_b`: Model stadard erorr of `b`
2221		+ `SE_c`: Model stadard erorr of `c`
2222		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
2223		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
2224		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
2225		+ `a2`: scrambling factor drift
2226		+ `b2`: compositional slope drift
2227		+ `c2`: WG offset drift
2228		+ `Np`: Number of standardization parameters to fit
2229		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
2230		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
2231		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
2232		'''
2233		for session in self.sessions:
2234			if 'd13Cwg_VPDB' not in self.sessions[session]:
2235				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
2236			if 'd18Owg_VSMOW' not in self.sessions[session]:
2237				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
2238			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
2239			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
2240
2241			self.msg(f'Computing repeatabilities for session {session}')
2242			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
2243			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
2244			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
2245
2246		if self.standardization_method == 'pooled':
2247			for session in self.sessions:
2248
2249				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
2250				i = self.standardization.var_names.index(f'a_{pf(session)}')
2251				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
2252
2253				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
2254				i = self.standardization.var_names.index(f'b_{pf(session)}')
2255				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
2256
2257				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
2258				i = self.standardization.var_names.index(f'c_{pf(session)}')
2259				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
2260
2261				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
2262				if self.sessions[session]['scrambling_drift']:
2263					i = self.standardization.var_names.index(f'a2_{pf(session)}')
2264					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
2265				else:
2266					self.sessions[session]['SE_a2'] = 0.
2267
2268				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
2269				if self.sessions[session]['slope_drift']:
2270					i = self.standardization.var_names.index(f'b2_{pf(session)}')
2271					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
2272				else:
2273					self.sessions[session]['SE_b2'] = 0.
2274
2275				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
2276				if self.sessions[session]['wg_drift']:
2277					i = self.standardization.var_names.index(f'c2_{pf(session)}')
2278					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
2279				else:
2280					self.sessions[session]['SE_c2'] = 0.
2281
2282				i = self.standardization.var_names.index(f'a_{pf(session)}')
2283				j = self.standardization.var_names.index(f'b_{pf(session)}')
2284				k = self.standardization.var_names.index(f'c_{pf(session)}')
2285				CM = np.zeros((6,6))
2286				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
2287				try:
2288					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
2289					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
2290					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
2291					try:
2292						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
2293						CM[3,4] = self.standardization.covar[i2,j2]
2294						CM[4,3] = self.standardization.covar[j2,i2]
2295					except ValueError:
2296						pass
2297					try:
2298						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2299						CM[3,5] = self.standardization.covar[i2,k2]
2300						CM[5,3] = self.standardization.covar[k2,i2]
2301					except ValueError:
2302						pass
2303				except ValueError:
2304					pass
2305				try:
2306					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
2307					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
2308					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
2309					try:
2310						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2311						CM[4,5] = self.standardization.covar[j2,k2]
2312						CM[5,4] = self.standardization.covar[k2,j2]
2313					except ValueError:
2314						pass
2315				except ValueError:
2316					pass
2317				try:
2318					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
2319					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
2320					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
2321				except ValueError:
2322					pass
2323
2324				self.sessions[session]['CM'] = CM
2325
2326		elif self.standardization_method == 'indep_sessions':
2327			pass # Not implemented yet
>>>>>>> master

Compute various statistics for each session.

  • Na: Number of anchor analyses in the session
  • Nu: Number of unknown analyses in the session
  • r_d13C_VPDB: δ13CVPDB repeatability of analyses within the session
  • r_d18O_VSMOW: δ18OVSMOW repeatability of analyses within the session
  • r_D47 or r_D48: Δ4x repeatability of analyses within the session
  • a: scrambling factor
  • b: compositional slope
  • c: WG offset
  • SE_a: Model stadard erorr of a
  • SE_b: Model stadard erorr of b
  • SE_c: Model stadard erorr of c
  • scrambling_drift (boolean): whether to allow a temporal drift in the scrambling factor (a)
  • slope_drift (boolean): whether to allow a temporal drift in the compositional slope (b)
  • wg_drift (boolean): whether to allow a temporal drift in the WG offset (c)
  • a2: scrambling factor drift
  • b2: compositional slope drift
  • c2: WG offset drift
  • Np: Number of standardization parameters to fit
  • CM: model covariance matrix for (a, b, c, a2, b2, c2)
  • d13Cwg_VPDB: δ13CVPDB of WG
  • d18Owg_VSMOW: δ18OVSMOW of WG
@make_verbal
def repeatabilities(self):
<<<<<<< HEAD
2328	@make_verbal
2329	def repeatabilities(self):
2330		'''
2331		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
2332		(for all samples, for anchors, and for unknowns).
2333		'''
2334		self.msg('Computing reproducibilities for all sessions')
2335
2336		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
2337		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
2338		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
2339		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
2340		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
=======
            
2330	@make_verbal
2331	def repeatabilities(self):
2332		'''
2333		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
2334		(for all samples, for anchors, and for unknowns).
2335		'''
2336		self.msg('Computing reproducibilities for all sessions')
2337
2338		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
2339		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
2340		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
2341		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
2342		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
>>>>>>> master

Compute analytical repeatabilities for δ13CVPDB, δ18OVSMOW, Δ4x (for all samples, for anchors, and for unknowns).

@make_verbal
def consolidate(self, tables=True, plots=True):
<<<<<<< HEAD
2343	@make_verbal
2344	def consolidate(self, tables = True, plots = True):
2345		'''
2346		Collect information about samples, sessions and repeatabilities.
2347		'''
2348		self.consolidate_samples()
2349		self.consolidate_sessions()
2350		self.repeatabilities()
2351
2352		if tables:
2353			self.summary()
2354			self.table_of_sessions()
2355			self.table_of_analyses()
2356			self.table_of_samples()
2357
2358		if plots:
2359			self.plot_sessions()
=======
            
2345	@make_verbal
2346	def consolidate(self, tables = True, plots = True):
2347		'''
2348		Collect information about samples, sessions and repeatabilities.
2349		'''
2350		self.consolidate_samples()
2351		self.consolidate_sessions()
2352		self.repeatabilities()
2353
2354		if tables:
2355			self.summary()
2356			self.table_of_sessions()
2357			self.table_of_analyses()
2358			self.table_of_samples()
2359
2360		if plots:
2361			self.plot_sessions()
>>>>>>> master

Collect information about samples, sessions and repeatabilities.

@make_verbal
def rmswd(self, samples='all samples', sessions='all sessions'):
<<<<<<< HEAD
2362	@make_verbal
2363	def rmswd(self,
2364		samples = 'all samples',
2365		sessions = 'all sessions',
2366		):
2367		'''
2368		Compute the χ2, root mean squared weighted deviation
2369		(i.e. reduced χ2), and corresponding degrees of freedom of the
2370		Δ4x values for samples in `samples` and sessions in `sessions`.
2371		
2372		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
2373		'''
2374		if samples == 'all samples':
2375			mysamples = [k for k in self.samples]
2376		elif samples == 'anchors':
2377			mysamples = [k for k in self.anchors]
2378		elif samples == 'unknowns':
2379			mysamples = [k for k in self.unknowns]
2380		else:
2381			mysamples = samples
2382
2383		if sessions == 'all sessions':
2384			sessions = [k for k in self.sessions]
2385
2386		chisq, Nf = 0, 0
2387		for sample in mysamples :
2388			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2389			if len(G) > 1 :
2390				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
2391				Nf += (len(G) - 1)
2392				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
2393		r = (chisq / Nf)**.5 if Nf > 0 else 0
2394		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
2395		return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}
=======
            
2364	@make_verbal
2365	def rmswd(self,
2366		samples = 'all samples',
2367		sessions = 'all sessions',
2368		):
2369		'''
2370		Compute the χ2, root mean squared weighted deviation
2371		(i.e. reduced χ2), and corresponding degrees of freedom of the
2372		Δ4x values for samples in `samples` and sessions in `sessions`.
2373		
2374		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
2375		'''
2376		if samples == 'all samples':
2377			mysamples = [k for k in self.samples]
2378		elif samples == 'anchors':
2379			mysamples = [k for k in self.anchors]
2380		elif samples == 'unknowns':
2381			mysamples = [k for k in self.unknowns]
2382		else:
2383			mysamples = samples
2384
2385		if sessions == 'all sessions':
2386			sessions = [k for k in self.sessions]
2387
2388		chisq, Nf = 0, 0
2389		for sample in mysamples :
2390			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2391			if len(G) > 1 :
2392				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
2393				Nf += (len(G) - 1)
2394				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
2395		r = (chisq / Nf)**.5 if Nf > 0 else 0
2396		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
2397		return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}
>>>>>>> master

Compute the χ2, root mean squared weighted deviation (i.e. reduced χ2), and corresponding degrees of freedom of the Δ4x values for samples in samples and sessions in sessions.

Only used in D4xdata.standardize() with method='indep_sessions'.

@make_verbal
def compute_r(self, key, samples='all samples', sessions='all sessions'):
<<<<<<< HEAD
2398	@make_verbal
2399	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
2400		'''
2401		Compute the repeatability of `[r[key] for r in self]`
2402		'''
2403		# NB: it's debatable whether rD47 should be computed
2404		# with Nf = len(self)-len(self.samples) instead of
2405		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
2406
2407		if samples == 'all samples':
2408			mysamples = [k for k in self.samples]
2409		elif samples == 'anchors':
2410			mysamples = [k for k in self.anchors]
2411		elif samples == 'unknowns':
2412			mysamples = [k for k in self.unknowns]
2413		else:
2414			mysamples = samples
2415
2416		if sessions == 'all sessions':
2417			sessions = [k for k in self.sessions]
2418
2419		if key in ['D47', 'D48']:
2420			chisq, Nf = 0, 0
2421			for sample in mysamples :
2422				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2423				if len(X) > 1 :
2424					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
2425					if sample in self.unknowns:
2426						Nf += len(X) - 1
2427					else:
2428						Nf += len(X)
2429			if samples in ['anchors', 'all samples']:
2430				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
2431			r = (chisq / Nf)**.5 if Nf > 0 else 0
2432
2433		else: # if key not in ['D47', 'D48']
2434			chisq, Nf = 0, 0
2435			for sample in mysamples :
2436				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2437				if len(X) > 1 :
2438					Nf += len(X) - 1
2439					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
2440			r = (chisq / Nf)**.5 if Nf > 0 else 0
2441
2442		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
2443		return r
=======
            
2400	@make_verbal
2401	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
2402		'''
2403		Compute the repeatability of `[r[key] for r in self]`
2404		'''
2405		# NB: it's debatable whether rD47 should be computed
2406		# with Nf = len(self)-len(self.samples) instead of
2407		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
2408
2409		if samples == 'all samples':
2410			mysamples = [k for k in self.samples]
2411		elif samples == 'anchors':
2412			mysamples = [k for k in self.anchors]
2413		elif samples == 'unknowns':
2414			mysamples = [k for k in self.unknowns]
2415		else:
2416			mysamples = samples
2417
2418		if sessions == 'all sessions':
2419			sessions = [k for k in self.sessions]
2420
2421		if key in ['D47', 'D48']:
2422			chisq, Nf = 0, 0
2423			for sample in mysamples :
2424				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2425				if len(X) > 1 :
2426					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
2427					if sample in self.unknowns:
2428						Nf += len(X) - 1
2429					else:
2430						Nf += len(X)
2431			if samples in ['anchors', 'all samples']:
2432				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
2433			r = (chisq / Nf)**.5 if Nf > 0 else 0
2434
2435		else: # if key not in ['D47', 'D48']
2436			chisq, Nf = 0, 0
2437			for sample in mysamples :
2438				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
2439				if len(X) > 1 :
2440					Nf += len(X) - 1
2441					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
2442			r = (chisq / Nf)**.5 if Nf > 0 else 0
2443
2444		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
2445		return r
>>>>>>> master

Compute the repeatability of [r[key] for r in self]

def sample_average(self, samples, weights='equal', normalize=True):
<<<<<<< HEAD
2445	def sample_average(self, samples, weights = 'equal', normalize = True):
2446		'''
2447		Weighted average Δ4x value of a group of samples, accounting for covariance.
2448
2449		Returns the weighed average Δ4x value and associated SE
2450		of a group of samples. Weights are equal by default. If `normalize` is
2451		true, `weights` will be rescaled so that their sum equals 1.
2452
2453		**Examples**
2454
2455		```python
2456		self.sample_average(['X','Y'], [1, 2])
2457		```
2458
2459		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
2460		where Δ4x(X) and Δ4x(Y) are the average Δ4x
2461		values of samples X and Y, respectively.
2462
2463		```python
2464		self.sample_average(['X','Y'], [1, -1], normalize = False)
2465		```
2466
2467		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
2468		'''
2469		if weights == 'equal':
2470			weights = [1/len(samples)] * len(samples)
2471
2472		if normalize:
2473			s = sum(weights)
2474			if s:
2475				weights = [w/s for w in weights]
2476
2477		try:
2478# 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
2479# 			C = self.standardization.covar[indices,:][:,indices]
2480			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
2481			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
2482			return correlated_sum(X, C, weights)
2483		except ValueError:
2484			return (0., 0.)
=======
            
2447	def sample_average(self, samples, weights = 'equal', normalize = True):
2448		'''
2449		Weighted average Δ4x value of a group of samples, accounting for covariance.
2450
2451		Returns the weighed average Δ4x value and associated SE
2452		of a group of samples. Weights are equal by default. If `normalize` is
2453		true, `weights` will be rescaled so that their sum equals 1.
2454
2455		**Examples**
2456
2457		```python
2458		self.sample_average(['X','Y'], [1, 2])
2459		```
2460
2461		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
2462		where Δ4x(X) and Δ4x(Y) are the average Δ4x
2463		values of samples X and Y, respectively.
2464
2465		```python
2466		self.sample_average(['X','Y'], [1, -1], normalize = False)
2467		```
2468
2469		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
2470		'''
2471		if weights == 'equal':
2472			weights = [1/len(samples)] * len(samples)
2473
2474		if normalize:
2475			s = sum(weights)
2476			if s:
2477				weights = [w/s for w in weights]
2478
2479		try:
2480# 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
2481# 			C = self.standardization.covar[indices,:][:,indices]
2482			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
2483			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
2484			return correlated_sum(X, C, weights)
2485		except ValueError:
2486			return (0., 0.)
>>>>>>> master

Weighted average Δ4x value of a group of samples, accounting for covariance.

Returns the weighed average Δ4x value and associated SE of a group of samples. Weights are equal by default. If normalize is true, weights will be rescaled so that their sum equals 1.

Examples

self.sample_average(['X','Y'], [1, 2])

returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3, where Δ4x(X) and Δ4x(Y) are the average Δ4x values of samples X and Y, respectively.

self.sample_average(['X','Y'], [1, -1], normalize = False)

returns the value and SE of the difference Δ4x(X) - Δ4x(Y).

def sample_D4x_covar(self, sample1, sample2=None):
<<<<<<< HEAD
2487	def sample_D4x_covar(self, sample1, sample2 = None):
2488		'''
2489		Covariance between Δ4x values of samples
2490
2491		Returns the error covariance between the average Δ4x values of two
2492		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
2493		returns the Δ4x variance for that sample.
2494		'''
2495		if sample2 is None:
2496			sample2 = sample1
2497		if self.standardization_method == 'pooled':
2498			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
2499			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
2500			return self.standardization.covar[i, j]
2501		elif self.standardization_method == 'indep_sessions':
2502			if sample1 == sample2:
2503				return self.samples[sample1][f'SE_D{self._4x}']**2
2504			else:
2505				c = 0
2506				for session in self.sessions:
2507					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
2508					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
2509					if sdata1 and sdata2:
2510						a = self.sessions[session]['a']
2511						# !! TODO: CM below does not account for temporal changes in standardization parameters
2512						CM = self.sessions[session]['CM'][:3,:3]
2513						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
2514						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
2515						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
2516						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
2517						c += (
2518							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
2519							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
2520							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
2521							@ CM
2522							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
2523							) / a**2
2524				return float(c)
=======
            
2489	def sample_D4x_covar(self, sample1, sample2 = None):
2490		'''
2491		Covariance between Δ4x values of samples
2492
2493		Returns the error covariance between the average Δ4x values of two
2494		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
2495		returns the Δ4x variance for that sample.
2496		'''
2497		if sample2 is None:
2498			sample2 = sample1
2499		if self.standardization_method == 'pooled':
2500			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
2501			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
2502			return self.standardization.covar[i, j]
2503		elif self.standardization_method == 'indep_sessions':
2504			if sample1 == sample2:
2505				return self.samples[sample1][f'SE_D{self._4x}']**2
2506			else:
2507				c = 0
2508				for session in self.sessions:
2509					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
2510					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
2511					if sdata1 and sdata2:
2512						a = self.sessions[session]['a']
2513						# !! TODO: CM below does not account for temporal changes in standardization parameters
2514						CM = self.sessions[session]['CM'][:3,:3]
2515						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
2516						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
2517						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
2518						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
2519						c += (
2520							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
2521							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
2522							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
2523							@ CM
2524							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
2525							) / a**2
2526				return float(c)
>>>>>>> master

Covariance between Δ4x values of samples

Returns the error covariance between the average Δ4x values of two samples. If if only sample_1 is specified, or if sample_1 == sample_2), returns the Δ4x variance for that sample.

def sample_D4x_correl(self, sample1, sample2=None):
<<<<<<< HEAD
2526	def sample_D4x_correl(self, sample1, sample2 = None):
2527		'''
2528		Correlation between Δ4x errors of samples
2529
2530		Returns the error correlation between the average Δ4x values of two samples.
2531		'''
2532		if sample2 is None or sample2 == sample1:
2533			return 1.
2534		return (
2535			self.sample_D4x_covar(sample1, sample2)
2536			/ self.unknowns[sample1][f'SE_D{self._4x}']
2537			/ self.unknowns[sample2][f'SE_D{self._4x}']
2538			)
=======
            
2528	def sample_D4x_correl(self, sample1, sample2 = None):
2529		'''
2530		Correlation between Δ4x errors of samples
2531
2532		Returns the error correlation between the average Δ4x values of two samples.
2533		'''
2534		if sample2 is None or sample2 == sample1:
2535			return 1.
2536		return (
2537			self.sample_D4x_covar(sample1, sample2)
2538			/ self.unknowns[sample1][f'SE_D{self._4x}']
2539			/ self.unknowns[sample2][f'SE_D{self._4x}']
2540			)
>>>>>>> master

Correlation between Δ4x errors of samples

Returns the error correlation between the average Δ4x values of two samples.

def plot_single_session( self, session, kw_plot_anchors={'ls': 'None', 'marker': 'x', 'mec': (0.75, 0, 0), 'mew': 0.75, 'ms': 4}, kw_plot_unknowns={'ls': 'None', 'marker': 'x', 'mec': (0, 0, 0.75), 'mew': 0.75, 'ms': 4}, kw_plot_anchor_avg={'ls': '-', 'marker': 'None', 'color': (0.75, 0, 0), 'lw': 0.75}, kw_plot_unknown_avg={'ls': '-', 'marker': 'None', 'color': (0, 0, 0.75), 'lw': 0.75}, kw_contour_error={'colors': [[0, 0, 0]], 'alpha': 0.5, 'linewidths': 0.75}, xylimits='free', x_label=None, y_label=None, error_contour_interval='auto', fig='new'):
<<<<<<< HEAD
2540	def plot_single_session(self,
2541		session,
2542		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
2543		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
2544		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
2545		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
2546		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
2547		xylimits = 'free', # | 'constant'
2548		x_label = None,
2549		y_label = None,
2550		error_contour_interval = 'auto',
2551		fig = 'new',
2552		):
2553		'''
2554		Generate plot for a single session
2555		'''
2556		if x_label is None:
2557			x_label = f'δ$_{{{self._4x}}}$ (‰)'
2558		if y_label is None:
2559			y_label = f'Δ$_{{{self._4x}}}$ (‰)'
2560
2561		out = _SessionPlot()
2562		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
2563		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
2564		
2565		if fig == 'new':
2566			out.fig = ppl.figure(figsize = (6,6))
2567			ppl.subplots_adjust(.1,.1,.9,.9)
2568
2569		out.anchor_analyses, = ppl.plot(
2570			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
2571			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
2572			**kw_plot_anchors)
2573		out.unknown_analyses, = ppl.plot(
2574			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
2575			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
2576			**kw_plot_unknowns)
2577		out.anchor_avg = ppl.plot(
2578			np.array([ np.array([
2579				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
2580				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
2581				]) for sample in anchors]).T,
2582			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
2583			**kw_plot_anchor_avg)
2584		out.unknown_avg = ppl.plot(
2585			np.array([ np.array([
2586				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
2587				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
2588				]) for sample in unknowns]).T,
2589			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
2590			**kw_plot_unknown_avg)
2591		if xylimits == 'constant':
2592			x = [r[f'd{self._4x}'] for r in self]
2593			y = [r[f'D{self._4x}'] for r in self]
2594			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
2595			w, h = x2-x1, y2-y1
2596			x1 -= w/20
2597			x2 += w/20
2598			y1 -= h/20
2599			y2 += h/20
2600			ppl.axis([x1, x2, y1, y2])
2601		elif xylimits == 'free':
2602			x1, x2, y1, y2 = ppl.axis()
2603		else:
2604			x1, x2, y1, y2 = ppl.axis(xylimits)
2605				
2606		if error_contour_interval != 'none':
2607			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
2608			XI,YI = np.meshgrid(xi, yi)
2609			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
2610			if error_contour_interval == 'auto':
2611				rng = np.max(SI) - np.min(SI)
2612				if rng <= 0.01:
2613					cinterval = 0.001
2614				elif rng <= 0.03:
2615					cinterval = 0.004
2616				elif rng <= 0.1:
2617					cinterval = 0.01
2618				elif rng <= 0.3:
2619					cinterval = 0.03
2620				elif rng <= 1.:
2621					cinterval = 0.1
2622				else:
2623					cinterval = 0.5
2624			else:
2625				cinterval = error_contour_interval
2626
2627			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
2628			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
2629			out.clabel = ppl.clabel(out.contour)
2630
2631		ppl.xlabel(x_label)
2632		ppl.ylabel(y_label)
2633		ppl.title(session, weight = 'bold')
2634		ppl.grid(alpha = .2)
2635		out.ax = ppl.gca()		
2636
2637		return out
=======
            
2542	def plot_single_session(self,
2543		session,
2544		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
2545		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
2546		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
2547		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
2548		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
2549		xylimits = 'free', # | 'constant'
2550		x_label = None,
2551		y_label = None,
2552		error_contour_interval = 'auto',
2553		fig = 'new',
2554		):
2555		'''
2556		Generate plot for a single session
2557		'''
2558		if x_label is None:
2559			x_label = f'δ$_{{{self._4x}}}$ (‰)'
2560		if y_label is None:
2561			y_label = f'Δ$_{{{self._4x}}}$ (‰)'
2562
2563		out = _SessionPlot()
2564		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
2565		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
2566		
2567		if fig == 'new':
2568			out.fig = ppl.figure(figsize = (6,6))
2569			ppl.subplots_adjust(.1,.1,.9,.9)
2570
2571		out.anchor_analyses, = ppl.plot(
2572			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
2573			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
2574			**kw_plot_anchors)
2575		out.unknown_analyses, = ppl.plot(
2576			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
2577			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
2578			**kw_plot_unknowns)
2579		out.anchor_avg = ppl.plot(
2580			np.array([ np.array([
2581				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
2582				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
2583				]) for sample in anchors]).T,
2584			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
2585			**kw_plot_anchor_avg)
2586		out.unknown_avg = ppl.plot(
2587			np.array([ np.array([
2588				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
2589				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
2590				]) for sample in unknowns]).T,
2591			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
2592			**kw_plot_unknown_avg)
2593		if xylimits == 'constant':
2594			x = [r[f'd{self._4x}'] for r in self]
2595			y = [r[f'D{self._4x}'] for r in self]
2596			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
2597			w, h = x2-x1, y2-y1
2598			x1 -= w/20
2599			x2 += w/20
2600			y1 -= h/20
2601			y2 += h/20
2602			ppl.axis([x1, x2, y1, y2])
2603		elif xylimits == 'free':
2604			x1, x2, y1, y2 = ppl.axis()
2605		else:
2606			x1, x2, y1, y2 = ppl.axis(xylimits)
2607				
2608		if error_contour_interval != 'none':
2609			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
2610			XI,YI = np.meshgrid(xi, yi)
2611			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
2612			if error_contour_interval == 'auto':
2613				rng = np.max(SI) - np.min(SI)
2614				if rng <= 0.01:
2615					cinterval = 0.001
2616				elif rng <= 0.03:
2617					cinterval = 0.004
2618				elif rng <= 0.1:
2619					cinterval = 0.01
2620				elif rng <= 0.3:
2621					cinterval = 0.03
2622				elif rng <= 1.:
2623					cinterval = 0.1
2624				else:
2625					cinterval = 0.5
2626			else:
2627				cinterval = error_contour_interval
2628
2629			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
2630			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
2631			out.clabel = ppl.clabel(out.contour)
2632
2633		ppl.xlabel(x_label)
2634		ppl.ylabel(y_label)
2635		ppl.title(session, weight = 'bold')
2636		ppl.grid(alpha = .2)
2637		out.ax = ppl.gca()		
2638
2639		return out
>>>>>>> master

Generate plot for a single session

def plot_residuals( self, hist=False, binwidth=0.6666666666666666, dir='output', filename=None, highlight=[], colors=None, figsize=None):
<<<<<<< HEAD
2639	def plot_residuals(
2640		self,
2641		hist = False,
2642		binwidth = 2/3,
2643		dir = 'output',
2644		filename = None,
2645		highlight = [],
2646		colors = None,
2647		figsize = None,
2648		):
2649		'''
2650		Plot residuals of each analysis as a function of time (actually, as a function of
2651		the order of analyses in the `D4xdata` object)
2652
2653		+ `hist`: whether to add a histogram of residuals
2654		+ `histbins`: specify bin edges for the histogram
2655		+ `dir`: the directory in which to save the plot
2656		+ `highlight`: a list of samples to highlight
2657		+ `colors`: a dict of `{<sample>: <color>}` for all samples
2658		+ `figsize`: (width, height) of figure
2659		'''
2660		# Layout
2661		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
2662		if hist:
2663			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
2664			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
2665		else:
2666			ppl.subplots_adjust(.08,.05,.78,.8)
2667			ax1 = ppl.subplot(111)
2668		
2669		# Colors
2670		N = len(self.anchors)
2671		if colors is None:
2672			if len(highlight) > 0:
2673				Nh = len(highlight)
2674				if Nh == 1:
2675					colors = {highlight[0]: (0,0,0)}
2676				elif Nh == 3:
2677					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
2678				elif Nh == 4:
2679					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
2680				else:
2681					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
2682			else:
2683				if N == 3:
2684					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
2685				elif N == 4:
2686					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
2687				else:
2688					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}
2689
2690		ppl.sca(ax1)
2691		
2692		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)
2693
2694		session = self[0]['Session']
2695		x1 = 0
2696# 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
2697		x_sessions = {}
2698		one_or_more_singlets = False
2699		one_or_more_multiplets = False
2700		multiplets = set()
2701		for k,r in enumerate(self):
2702			if r['Session'] != session:
2703				x2 = k-1
2704				x_sessions[session] = (x1+x2)/2
2705				ppl.axvline(k - 0.5, color = 'k', lw = .5)
2706				session = r['Session']
2707				x1 = k
2708			singlet = len(self.samples[r['Sample']]['data']) == 1
2709			if not singlet:
2710				multiplets.add(r['Sample'])
2711			if r['Sample'] in self.unknowns:
2712				if singlet:
2713					one_or_more_singlets = True
2714				else:
2715					one_or_more_multiplets = True
2716			kw = dict(
2717				marker = 'x' if singlet else '+',
2718				ms = 4 if singlet else 5,
2719				ls = 'None',
2720				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
2721				mew = 1,
2722				alpha = 0.2 if singlet else 1,
2723				)
2724			if highlight and r['Sample'] not in highlight:
2725				kw['alpha'] = 0.2
2726			ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
2727		x2 = k
2728		x_sessions[session] = (x1+x2)/2
2729
2730		ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
2731		ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
2732		if not hist:
2733			ppl.text(len(self), self.repeatability['r_D47']*1000, f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
2734			ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f"   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center')
2735
2736		xmin, xmax, ymin, ymax = ppl.axis()
2737		for s in x_sessions:
2738			ppl.text(
2739				x_sessions[s],
2740				ymax +1,
2741				s,
2742				va = 'bottom',
2743				**(
2744					dict(ha = 'center')
2745					if len(self.sessions[s]['data']) > (0.15 * len(self))
2746					else dict(ha = 'left', rotation = 45)
2747					)
2748				)
2749
2750		if hist:
2751			ppl.sca(ax2)
2752
2753		for s in colors:
2754			kw['marker'] = '+'
2755			kw['ms'] = 5
2756			kw['mec'] = colors[s]
2757			kw['label'] = s
2758			kw['alpha'] = 1
2759			ppl.plot([], [], **kw)
2760
2761		kw['mec'] = (0,0,0)
2762
2763		if one_or_more_singlets:
2764			kw['marker'] = 'x'
2765			kw['ms'] = 4
2766			kw['alpha'] = .2
2767			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
2768			ppl.plot([], [], **kw)
2769
2770		if one_or_more_multiplets:
2771			kw['marker'] = '+'
2772			kw['ms'] = 4
2773			kw['alpha'] = 1
2774			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
2775			ppl.plot([], [], **kw)
2776
2777		if hist:
2778			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
2779		else:
2780			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
2781		leg.set_zorder(-1000)
2782
2783		ppl.sca(ax1)
2784
2785		ppl.ylabel('Δ$_{47}$ residuals (ppm)')
2786		ppl.xticks([])
2787		ppl.axis([-1, len(self), None, None])
2788
2789		if hist:
2790			ppl.sca(ax2)
2791			X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self if r['Sample'] in multiplets]
2792			ppl.hist(
2793				X,
2794				orientation = 'horizontal',
2795				histtype = 'stepfilled',
2796				ec = [.4]*3,
2797				fc = [.25]*3,
2798				alpha = .25,
2799				bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
2800				)
2801			ppl.axis([None, None, ymin, ymax])
2802			ppl.text(0, 0,
2803				f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm\n   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
2804				size = 8,
2805				alpha = 1,
2806				va = 'center',
2807				ha = 'left',
2808				)
2809
2810			ppl.xticks([])
2811			ppl.yticks([])
2812# 			ax2.spines['left'].set_visible(False)
2813			ax2.spines['right'].set_visible(False)
2814			ax2.spines['top'].set_visible(False)
2815			ax2.spines['bottom'].set_visible(False)
2816
2817
2818		if not os.path.exists(dir):
2819			os.makedirs(dir)
2820		if filename is None:
2821			return fig
2822		elif filename == '':
2823			filename = f'D{self._4x}_residuals.pdf'
2824		ppl.savefig(f'{dir}/{filename}')
2825		ppl.close(fig)
=======
            
2641	def plot_residuals(
2642		self,
2643		hist = False,
2644		binwidth = 2/3,
2645		dir = 'output',
2646		filename = None,
2647		highlight = [],
2648		colors = None,
2649		figsize = None,
2650		):
2651		'''
2652		Plot residuals of each analysis as a function of time (actually, as a function of
2653		the order of analyses in the `D4xdata` object)
2654
2655		+ `hist`: whether to add a histogram of residuals
2656		+ `histbins`: specify bin edges for the histogram
2657		+ `dir`: the directory in which to save the plot
2658		+ `highlight`: a list of samples to highlight
2659		+ `colors`: a dict of `{<sample>: <color>}` for all samples
2660		+ `figsize`: (width, height) of figure
2661		'''
2662		# Layout
2663		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
2664		if hist:
2665			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
2666			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
2667		else:
2668			ppl.subplots_adjust(.08,.05,.78,.8)
2669			ax1 = ppl.subplot(111)
2670		
2671		# Colors
2672		N = len(self.anchors)
2673		if colors is None:
2674			if len(highlight) > 0:
2675				Nh = len(highlight)
2676				if Nh == 1:
2677					colors = {highlight[0]: (0,0,0)}
2678				elif Nh == 3:
2679					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
2680				elif Nh == 4:
2681					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
2682				else:
2683					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
2684			else:
2685				if N == 3:
2686					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
2687				elif N == 4:
2688					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
2689				else:
2690					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}
2691
2692		ppl.sca(ax1)
2693		
2694		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)
2695
2696		session = self[0]['Session']
2697		x1 = 0
2698# 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
2699		x_sessions = {}
2700		one_or_more_singlets = False
2701		one_or_more_multiplets = False
2702		multiplets = set()
2703		for k,r in enumerate(self):
2704			if r['Session'] != session:
2705				x2 = k-1
2706				x_sessions[session] = (x1+x2)/2
2707				ppl.axvline(k - 0.5, color = 'k', lw = .5)
2708				session = r['Session']
2709				x1 = k
2710			singlet = len(self.samples[r['Sample']]['data']) == 1
2711			if not singlet:
2712				multiplets.add(r['Sample'])
2713			if r['Sample'] in self.unknowns:
2714				if singlet:
2715					one_or_more_singlets = True
2716				else:
2717					one_or_more_multiplets = True
2718			kw = dict(
2719				marker = 'x' if singlet else '+',
2720				ms = 4 if singlet else 5,
2721				ls = 'None',
2722				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
2723				mew = 1,
2724				alpha = 0.2 if singlet else 1,
2725				)
2726			if highlight and r['Sample'] not in highlight:
2727				kw['alpha'] = 0.2
2728			ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
2729		x2 = k
2730		x_sessions[session] = (x1+x2)/2
2731
2732		ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
2733		ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
2734		if not hist:
2735			ppl.text(len(self), self.repeatability['r_D47']*1000, f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
2736			ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f"   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center')
2737
2738		xmin, xmax, ymin, ymax = ppl.axis()
2739		for s in x_sessions:
2740			ppl.text(
2741				x_sessions[s],
2742				ymax +1,
2743				s,
2744				va = 'bottom',
2745				**(
2746					dict(ha = 'center')
2747					if len(self.sessions[s]['data']) > (0.15 * len(self))
2748					else dict(ha = 'left', rotation = 45)
2749					)
2750				)
2751
2752		if hist:
2753			ppl.sca(ax2)
2754
2755		for s in colors:
2756			kw['marker'] = '+'
2757			kw['ms'] = 5
2758			kw['mec'] = colors[s]
2759			kw['label'] = s
2760			kw['alpha'] = 1
2761			ppl.plot([], [], **kw)
2762
2763		kw['mec'] = (0,0,0)
2764
2765		if one_or_more_singlets:
2766			kw['marker'] = 'x'
2767			kw['ms'] = 4
2768			kw['alpha'] = .2
2769			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
2770			ppl.plot([], [], **kw)
2771
2772		if one_or_more_multiplets:
2773			kw['marker'] = '+'
2774			kw['ms'] = 4
2775			kw['alpha'] = 1
2776			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
2777			ppl.plot([], [], **kw)
2778
2779		if hist:
2780			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
2781		else:
2782			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
2783		leg.set_zorder(-1000)
2784
2785		ppl.sca(ax1)
2786
2787		ppl.ylabel('Δ$_{47}$ residuals (ppm)')
2788		ppl.xticks([])
2789		ppl.axis([-1, len(self), None, None])
2790
2791		if hist:
2792			ppl.sca(ax2)
2793			X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self if r['Sample'] in multiplets]
2794			ppl.hist(
2795				X,
2796				orientation = 'horizontal',
2797				histtype = 'stepfilled',
2798				ec = [.4]*3,
2799				fc = [.25]*3,
2800				alpha = .25,
2801				bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
2802				)
2803			ppl.axis([None, None, ymin, ymax])
2804			ppl.text(0, 0,
2805				f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm\n   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
2806				size = 8,
2807				alpha = 1,
2808				va = 'center',
2809				ha = 'left',
2810				)
2811
2812			ppl.xticks([])
2813			ppl.yticks([])
2814# 			ax2.spines['left'].set_visible(False)
2815			ax2.spines['right'].set_visible(False)
2816			ax2.spines['top'].set_visible(False)
2817			ax2.spines['bottom'].set_visible(False)
2818
2819
2820		if not os.path.exists(dir):
2821			os.makedirs(dir)
2822		if filename is None:
2823			return fig
2824		elif filename == '':
2825			filename = f'D{self._4x}_residuals.pdf'
2826		ppl.savefig(f'{dir}/{filename}')
2827		ppl.close(fig)
>>>>>>> master

Plot residuals of each analysis as a function of time (actually, as a function of the order of analyses in the D4xdata object)

  • hist: whether to add a histogram of residuals
  • histbins: specify bin edges for the histogram
  • dir: the directory in which to save the plot
  • highlight: a list of samples to highlight
  • colors: a dict of {<sample>: <color>} for all samples
  • figsize: (width, height) of figure
def simulate(self, *args, **kwargs):
<<<<<<< HEAD
2828	def simulate(self, *args, **kwargs):
2829		'''
2830		Legacy function with warning message pointing to `virtual_data()`
2831		'''
2832		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
=======
            
2830	def simulate(self, *args, **kwargs):
2831		'''
2832		Legacy function with warning message pointing to `virtual_data()`
2833		'''
2834		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
>>>>>>> master

Legacy function with warning message pointing to virtual_data()

def plot_distribution_of_analyses( self, dir='output', filename=None, vs_time=False, figsize=(6, 4), subplots_adjust=(0.02, 0.13, 0.85, 0.8), output=None):
<<<<<<< HEAD
2834	def plot_distribution_of_analyses(
2835		self,
2836		dir = 'output',
2837		filename = None,
2838		vs_time = False,
2839		figsize = (6,4),
2840		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
2841		output = None,
2842		):
2843		'''
2844		Plot temporal distribution of all analyses in the data set.
2845		
2846		**Parameters**
2847
2848		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
2849		'''
2850
2851		asamples = [s for s in self.anchors]
2852		usamples = [s for s in self.unknowns]
2853		if output is None or output == 'fig':
2854			fig = ppl.figure(figsize = figsize)
2855			ppl.subplots_adjust(*subplots_adjust)
2856		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
2857		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
2858		Xmax += (Xmax-Xmin)/40
2859		Xmin -= (Xmax-Xmin)/41
2860		for k, s in enumerate(asamples + usamples):
2861			if vs_time:
2862				X = [r['TimeTag'] for r in self if r['Sample'] == s]
2863			else:
2864				X = [x for x,r in enumerate(self) if r['Sample'] == s]
2865			Y = [-k for x in X]
2866			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
2867			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
2868			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
2869		ppl.axis([Xmin, Xmax, -k-1, 1])
2870		ppl.xlabel('\ntime')
2871		ppl.gca().annotate('',
2872			xy = (0.6, -0.02),
2873			xycoords = 'axes fraction',
2874			xytext = (.4, -0.02), 
2875            arrowprops = dict(arrowstyle = "->", color = 'k'),
2876            )
2877			
2878
2879		x2 = -1
2880		for session in self.sessions:
2881			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
2882			if vs_time:
2883				ppl.axvline(x1, color = 'k', lw = .75)
2884			if x2 > -1:
2885				if not vs_time:
2886					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
2887			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
2888# 			from xlrd import xldate_as_datetime
2889# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
2890			if vs_time:
2891				ppl.axvline(x2, color = 'k', lw = .75)
2892				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
2893			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
2894
2895		ppl.xticks([])
2896		ppl.yticks([])
2897
2898		if output is None:
2899			if not os.path.exists(dir):
2900				os.makedirs(dir)
2901			if filename == None:
2902				filename = f'D{self._4x}_distribution_of_analyses.pdf'
2903			ppl.savefig(f'{dir}/{filename}')
2904			ppl.close(fig)
2905		elif output == 'ax':
2906			return ppl.gca()
2907		elif output == 'fig':
2908			return fig
=======
            
2836	def plot_distribution_of_analyses(
2837		self,
2838		dir = 'output',
2839		filename = None,
2840		vs_time = False,
2841		figsize = (6,4),
2842		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
2843		output = None,
2844		):
2845		'''
2846		Plot temporal distribution of all analyses in the data set.
2847		
2848		**Parameters**
2849
2850		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
2851		'''
2852
2853		asamples = [s for s in self.anchors]
2854		usamples = [s for s in self.unknowns]
2855		if output is None or output == 'fig':
2856			fig = ppl.figure(figsize = figsize)
2857			ppl.subplots_adjust(*subplots_adjust)
2858		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
2859		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
2860		Xmax += (Xmax-Xmin)/40
2861		Xmin -= (Xmax-Xmin)/41
2862		for k, s in enumerate(asamples + usamples):
2863			if vs_time:
2864				X = [r['TimeTag'] for r in self if r['Sample'] == s]
2865			else:
2866				X = [x for x,r in enumerate(self) if r['Sample'] == s]
2867			Y = [-k for x in X]
2868			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
2869			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
2870			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
2871		ppl.axis([Xmin, Xmax, -k-1, 1])
2872		ppl.xlabel('\ntime')
2873		ppl.gca().annotate('',
2874			xy = (0.6, -0.02),
2875			xycoords = 'axes fraction',
2876			xytext = (.4, -0.02), 
2877            arrowprops = dict(arrowstyle = "->", color = 'k'),
2878            )
2879			
2880
2881		x2 = -1
2882		for session in self.sessions:
2883			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
2884			if vs_time:
2885				ppl.axvline(x1, color = 'k', lw = .75)
2886			if x2 > -1:
2887				if not vs_time:
2888					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
2889			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
2890# 			from xlrd import xldate_as_datetime
2891# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
2892			if vs_time:
2893				ppl.axvline(x2, color = 'k', lw = .75)
2894				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
2895			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
2896
2897		ppl.xticks([])
2898		ppl.yticks([])
2899
2900		if output is None:
2901			if not os.path.exists(dir):
2902				os.makedirs(dir)
2903			if filename == None:
2904				filename = f'D{self._4x}_distribution_of_analyses.pdf'
2905			ppl.savefig(f'{dir}/{filename}')
2906			ppl.close(fig)
2907		elif output == 'ax':
2908			return ppl.gca()
2909		elif output == 'fig':
2910			return fig
>>>>>>> master

Plot temporal distribution of all analyses in the data set.

Parameters

  • vs_time: if True, plot as a function of TimeTag rather than sequentially.
Inherited Members
builtins.list
clear
copy
append
insert
extend
pop
remove
index
count
reverse
sort
class D47data(D4xdata):
<<<<<<< HEAD
2911class D47data(D4xdata):
2912	'''
2913	Store and process data for a large set of Δ47 analyses,
2914	usually comprising more than one analytical session.
2915	'''
2916
2917	Nominal_D4x = {
2918		'ETH-1':   0.2052,
2919		'ETH-2':   0.2085,
2920		'ETH-3':   0.6132,
2921		'ETH-4':   0.4511,
2922		'IAEA-C1': 0.3018,
2923		'IAEA-C2': 0.6409,
2924		'MERCK':   0.5135,
2925		} # I-CDES (Bernasconi et al., 2021)
2926	'''
2927	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
2928	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
2929	reference frame.
2930
2931	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
2932	```py
2933	{
2934		'ETH-1'   : 0.2052,
2935		'ETH-2'   : 0.2085,
2936		'ETH-3'   : 0.6132,
2937		'ETH-4'   : 0.4511,
2938		'IAEA-C1' : 0.3018,
2939		'IAEA-C2' : 0.6409,
2940		'MERCK'   : 0.5135,
2941	}
2942	```
2943	'''
2944
2945
2946	@property
2947	def Nominal_D47(self):
2948		return self.Nominal_D4x
2949	
2950
2951	@Nominal_D47.setter
2952	def Nominal_D47(self, new):
2953		self.Nominal_D4x = dict(**new)
2954		self.refresh()
2955
2956
2957	def __init__(self, l = [], **kwargs):
2958		'''
2959		**Parameters:** same as `D4xdata.__init__()`
2960		'''
2961		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
2962
2963
2964	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
2965		'''
2966		Find all samples for which `Teq` is specified, compute equilibrium Δ47
2967		value for that temperature, and add treat these samples as additional anchors.
2968
2969		**Parameters**
2970
2971		+ `fCo2eqD47`: Which CO2 equilibrium law to use
2972		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
2973		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
2974		+ `priority`: if `replace`: forget old anchors and only use the new ones;
2975		if `new`: keep pre-existing anchors but update them in case of conflict
2976		between old and new Δ47 values;
2977		if `old`: keep pre-existing anchors but preserve their original Δ47
2978		values in case of conflict.
2979		'''
2980		f = {
2981			'petersen': fCO2eqD47_Petersen,
2982			'wang': fCO2eqD47_Wang,
2983			}[fCo2eqD47]
2984		foo = {}
2985		for r in self:
2986			if 'Teq' in r:
2987				if r['Sample'] in foo:
2988					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
2989				else:
2990					foo[r['Sample']] = f(r['Teq'])
2991			else:
2992					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
2993
2994		if priority == 'replace':
2995			self.Nominal_D47 = {}
2996		for s in foo:
2997			if priority != 'old' or s not in self.Nominal_D47:
2998				self.Nominal_D47[s] = foo[s]
=======
            
2913class D47data(D4xdata):
2914	'''
2915	Store and process data for a large set of Δ47 analyses,
2916	usually comprising more than one analytical session.
2917	'''
2918
2919	Nominal_D4x = {
2920		'ETH-1':   0.2052,
2921		'ETH-2':   0.2085,
2922		'ETH-3':   0.6132,
2923		'ETH-4':   0.4511,
2924		'IAEA-C1': 0.3018,
2925		'IAEA-C2': 0.6409,
2926		'MERCK':   0.5135,
2927		} # I-CDES (Bernasconi et al., 2021)
2928	'''
2929	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
2930	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
2931	reference frame.
2932
2933	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
2934	```py
2935	{
2936		'ETH-1'   : 0.2052,
2937		'ETH-2'   : 0.2085,
2938		'ETH-3'   : 0.6132,
2939		'ETH-4'   : 0.4511,
2940		'IAEA-C1' : 0.3018,
2941		'IAEA-C2' : 0.6409,
2942		'MERCK'   : 0.5135,
2943	}
2944	```
2945	'''
2946
2947
2948	@property
2949	def Nominal_D47(self):
2950		return self.Nominal_D4x
2951	
2952
2953	@Nominal_D47.setter
2954	def Nominal_D47(self, new):
2955		self.Nominal_D4x = dict(**new)
2956		self.refresh()
2957
2958
2959	def __init__(self, l = [], **kwargs):
2960		'''
2961		**Parameters:** same as `D4xdata.__init__()`
2962		'''
2963		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
2964
2965
2966	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
2967		'''
2968		Find all samples for which `Teq` is specified, compute equilibrium Δ47
2969		value for that temperature, and add treat these samples as additional anchors.
2970
2971		**Parameters**
2972
2973		+ `fCo2eqD47`: Which CO2 equilibrium law to use
2974		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
2975		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
2976		+ `priority`: if `replace`: forget old anchors and only use the new ones;
2977		if `new`: keep pre-existing anchors but update them in case of conflict
2978		between old and new Δ47 values;
2979		if `old`: keep pre-existing anchors but preserve their original Δ47
2980		values in case of conflict.
2981		'''
2982		f = {
2983			'petersen': fCO2eqD47_Petersen,
2984			'wang': fCO2eqD47_Wang,
2985			}[fCo2eqD47]
2986		foo = {}
2987		for r in self:
2988			if 'Teq' in r:
2989				if r['Sample'] in foo:
2990					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
2991				else:
2992					foo[r['Sample']] = f(r['Teq'])
2993			else:
2994					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
2995
2996		if priority == 'replace':
2997			self.Nominal_D47 = {}
2998		for s in foo:
2999			if priority != 'old' or s not in self.Nominal_D47:
3000				self.Nominal_D47[s] = foo[s]
>>>>>>> master

Store and process data for a large set of Δ47 analyses, usually comprising more than one analytical session.

D47data(l=[], **kwargs)
<<<<<<< HEAD
2957	def __init__(self, l = [], **kwargs):
2958		'''
2959		**Parameters:** same as `D4xdata.__init__()`
2960		'''
2961		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
=======
            
2959	def __init__(self, l = [], **kwargs):
2960		'''
2961		**Parameters:** same as `D4xdata.__init__()`
2962		'''
2963		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
>>>>>>> master

Parameters: same as D4xdata.__init__()

Nominal_D4x = {'ETH-1': 0.2052, 'ETH-2': 0.2085, 'ETH-3': 0.6132, 'ETH-4': 0.4511, 'IAEA-C1': 0.3018, 'IAEA-C2': 0.6409, 'MERCK': 0.5135}

Nominal Δ47 values assigned to the Δ47 anchor samples, used by D47data.standardize() to normalize unknown samples to an absolute Δ47 reference frame.

By default equal to (after Bernasconi et al. (2021)):

{
        'ETH-1'   : 0.2052,
        'ETH-2'   : 0.2085,
        'ETH-3'   : 0.6132,
        'ETH-4'   : 0.4511,
        'IAEA-C1' : 0.3018,
        'IAEA-C2' : 0.6409,
        'MERCK'   : 0.5135,
}
def D47fromTeq(self, fCo2eqD47='petersen', priority='new'):
<<<<<<< HEAD
2964	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
2965		'''
2966		Find all samples for which `Teq` is specified, compute equilibrium Δ47
2967		value for that temperature, and add treat these samples as additional anchors.
2968
2969		**Parameters**
2970
2971		+ `fCo2eqD47`: Which CO2 equilibrium law to use
2972		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
2973		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
2974		+ `priority`: if `replace`: forget old anchors and only use the new ones;
2975		if `new`: keep pre-existing anchors but update them in case of conflict
2976		between old and new Δ47 values;
2977		if `old`: keep pre-existing anchors but preserve their original Δ47
2978		values in case of conflict.
2979		'''
2980		f = {
2981			'petersen': fCO2eqD47_Petersen,
2982			'wang': fCO2eqD47_Wang,
2983			}[fCo2eqD47]
2984		foo = {}
2985		for r in self:
2986			if 'Teq' in r:
2987				if r['Sample'] in foo:
2988					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
2989				else:
2990					foo[r['Sample']] = f(r['Teq'])
2991			else:
2992					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
2993
2994		if priority == 'replace':
2995			self.Nominal_D47 = {}
2996		for s in foo:
2997			if priority != 'old' or s not in self.Nominal_D47:
2998				self.Nominal_D47[s] = foo[s]
=======
            
2966	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
2967		'''
2968		Find all samples for which `Teq` is specified, compute equilibrium Δ47
2969		value for that temperature, and add treat these samples as additional anchors.
2970
2971		**Parameters**
2972
2973		+ `fCo2eqD47`: Which CO2 equilibrium law to use
2974		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
2975		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
2976		+ `priority`: if `replace`: forget old anchors and only use the new ones;
2977		if `new`: keep pre-existing anchors but update them in case of conflict
2978		between old and new Δ47 values;
2979		if `old`: keep pre-existing anchors but preserve their original Δ47
2980		values in case of conflict.
2981		'''
2982		f = {
2983			'petersen': fCO2eqD47_Petersen,
2984			'wang': fCO2eqD47_Wang,
2985			}[fCo2eqD47]
2986		foo = {}
2987		for r in self:
2988			if 'Teq' in r:
2989				if r['Sample'] in foo:
2990					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
2991				else:
2992					foo[r['Sample']] = f(r['Teq'])
2993			else:
2994					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
2995
2996		if priority == 'replace':
2997			self.Nominal_D47 = {}
2998		for s in foo:
2999			if priority != 'old' or s not in self.Nominal_D47:
3000				self.Nominal_D47[s] = foo[s]
>>>>>>> master

Find all samples for which Teq is specified, compute equilibrium Δ47 value for that temperature, and add treat these samples as additional anchors.

Parameters

  • fCo2eqD47: Which CO2 equilibrium law to use (petersen: Petersen et al. (2019); wang: Wang et al. (2019)).
  • priority: if replace: forget old anchors and only use the new ones; if new: keep pre-existing anchors but update them in case of conflict between old and new Δ47 values; if old: keep pre-existing anchors but preserve their original Δ47 values in case of conflict.
class D48data(D4xdata):
<<<<<<< HEAD
3003class D48data(D4xdata):
3004	'''
3005	Store and process data for a large set of Δ48 analyses,
3006	usually comprising more than one analytical session.
3007	'''
3008
3009	Nominal_D4x = {
3010		'ETH-1':  0.138,
3011		'ETH-2':  0.138,
3012		'ETH-3':  0.270,
3013		'ETH-4':  0.223,
3014		'GU-1':  -0.419,
3015		} # (Fiebig et al., 2019, 2021)
3016	'''
3017	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
3018	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
3019	reference frame.
3020
3021	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
3022	Fiebig et al. (in press)):
3023
3024	```py
3025	{
3026		'ETH-1' :  0.138,
3027		'ETH-2' :  0.138,
3028		'ETH-3' :  0.270,
3029		'ETH-4' :  0.223,
3030		'GU-1'  : -0.419,
3031	}
3032	```
3033	'''
3034
3035
3036	@property
3037	def Nominal_D48(self):
3038		return self.Nominal_D4x
3039
3040	
3041	@Nominal_D48.setter
3042	def Nominal_D48(self, new):
3043		self.Nominal_D4x = dict(**new)
3044		self.refresh()
3045
3046
3047	def __init__(self, l = [], **kwargs):
3048		'''
3049		**Parameters:** same as `D4xdata.__init__()`
3050		'''
3051		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
=======
            
3005class D48data(D4xdata):
3006	'''
3007	Store and process data for a large set of Δ48 analyses,
3008	usually comprising more than one analytical session.
3009	'''
3010
3011	Nominal_D4x = {
3012		'ETH-1':  0.138,
3013		'ETH-2':  0.138,
3014		'ETH-3':  0.270,
3015		'ETH-4':  0.223,
3016		'GU-1':  -0.419,
3017		} # (Fiebig et al., 2019, 2021)
3018	'''
3019	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
3020	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
3021	reference frame.
3022
3023	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
3024	Fiebig et al. (in press)):
3025
3026	```py
3027	{
3028		'ETH-1' :  0.138,
3029		'ETH-2' :  0.138,
3030		'ETH-3' :  0.270,
3031		'ETH-4' :  0.223,
3032		'GU-1'  : -0.419,
3033	}
3034	```
3035	'''
3036
3037
3038	@property
3039	def Nominal_D48(self):
3040		return self.Nominal_D4x
3041
3042	
3043	@Nominal_D48.setter
3044	def Nominal_D48(self, new):
3045		self.Nominal_D4x = dict(**new)
3046		self.refresh()
3047
3048
3049	def __init__(self, l = [], **kwargs):
3050		'''
3051		**Parameters:** same as `D4xdata.__init__()`
3052		'''
3053		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
>>>>>>> master

Store and process data for a large set of Δ48 analyses, usually comprising more than one analytical session.

D48data(l=[], **kwargs)
<<<<<<< HEAD
3047	def __init__(self, l = [], **kwargs):
3048		'''
3049		**Parameters:** same as `D4xdata.__init__()`
3050		'''
3051		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
=======
            
3049	def __init__(self, l = [], **kwargs):
3050		'''
3051		**Parameters:** same as `D4xdata.__init__()`
3052		'''
3053		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
>>>>>>> master

Parameters: same as D4xdata.__init__()

Nominal_D4x = {'ETH-1': 0.138, 'ETH-2': 0.138, 'ETH-3': 0.27, 'ETH-4': 0.223, 'GU-1': -0.419}

Nominal Δ48 values assigned to the Δ48 anchor samples, used by D48data.standardize() to normalize unknown samples to an absolute Δ48 reference frame.

By default equal to (after Fiebig et al. (2019), Fiebig et al. (in press)):

{
        'ETH-1' :  0.138,
        'ETH-2' :  0.138,
        'ETH-3' :  0.270,
        'ETH-4' :  0.223,
        'GU-1'  : -0.419,
}